Compare commits

...
Author SHA1 Message Date
maxjivi05 ee2284f330 l10n: follow the reworded Force fullscreen hints (#276) 2026-10-05 13:38:50 -04:00
maxjivi05 0bb0ca8141 Merge main into feat/language-selection 2026-10-05 13:36:33 -04:00
maxjivi05 2a1c4e819b Merge main into feat/language-selection
Brings in the app UI scale presets (#272), compact settings headers (#268), the
undimmed session menu (#267) and the MangoHud battery fix (#270). Setup's
Launcher group lists Language first and App scale after it; the scale's three
new strings are translated.
2026-10-05 13:35:53 -04:00
maxjivi05 2467db90b4 Merge main into feat/language-selection 2026-10-05 08:11:42 -04:00
maxjivi05 7558503b0d Merge main into feat/language-selection 2026-10-05 07:38:15 -04:00
maxjivi05 0efc34f1e5 Tabs: scroll the chosen tab into view
Steam session settings now has six tabs; on a small phone the last one (Steam)
was left cut off at the strip's edge even when chosen.
2026-10-05 01:47:01 -04:00
maxjivi05 ca5f3c97fc Merge main into feat/language-selection
Brings in the display settings and in-session controls rework (#247). Its
layout and behaviour are kept; the choices it shows (scaling filters, frame
limits, texture options, effect presets, Back order, the auto touch mode)
use the translated labels, and its new strings are translated.
2026-10-05 01:27:34 -04:00
maxjivi05 f632112f0f Menus: open scrolled to the current choice
On a short screen the language menu shows five entries; with Chinese chosen
the checked one was below the fold. A menu now brings its checked entry into
view when it opens.
2026-10-05 00:59:36 -04:00
maxjivi05 cf995915be l10n: name the update channels in Spanish, Korean and Taiwanese Chinese
Japanese and both other Chinese translations call Stable and Preview by
translated names; these three kept the English ones.
2026-10-05 00:55:42 -04:00
maxjivi05 ed41001005 Updates: stack the channels under the status on a small phone
Side by side in a pane under 720dp, each channel's name and date were cut to a
word in every language. Also lowercase the relative time inside "Checked
...", which Android capitalizes to stand alone (Spanish "Comprobado Hace 5
minutos").
2026-10-05 00:55:42 -04:00
maxjivi05 8e4bade2b4 Desktop page: fit the desktop card and the emulator tiles on a small phone
On a 640dp-wide screen the Japanese "Install & open desktop" button left the
card's title a character a line, and three tiles a row left the emulators'
names as an ellipsis in every language. The button now goes under the title
when the card is under 640dp, and the grid shows two tiles a row under 720dp.
2026-10-05 00:52:43 -04:00
maxjivi05 2cac9c7e94 l10n: shorten the Spanish and Japanese first-run runtime note to fit its pill 2026-10-05 00:49:52 -04:00
maxjivi05 0e018ce09f Tabs: close up a tab row that does not fit before scrolling it
Spanish labels on a 640dp-wide screen pushed Components' last tab past the
edge. A row that overflows now narrows its tabs' padding first.
2026-10-05 00:49:52 -04:00
maxjivi05 644c63358f GPU drivers: let the support verdict wrap beside the GPU's name
On a small phone the verdict took the width it needed and squeezed the name
column to a letter a line ("ranc / hu"). It now shares the row and wraps.
2026-10-05 00:49:52 -04:00
maxjivi05 7f63c514a8 l10n: translate the Back button's order labels
The Session tab's Back setting showed "1: menu 2: QAM" in every language: the
two orders were Kotlin constants. They are string resources now, written as
"Menu → QAM" and "QAM → Menu" and translated.
2026-10-05 00:32:54 -04:00
maxjivi05 4a19ee9e55 l10n: name the Play button in Spanish and Japanese hints as the button reads 2026-10-05 00:05:38 -04:00
maxjivi05 af252d3b76 Menus: show that a list runs on below the fold
On a phone in landscape the language menu shows five of its eight entries and
ends on a whole row, so nothing hints that more follow. While the list can
scroll further it now fades out at the bottom over a down arrow.
2026-10-05 00:05:38 -04:00
maxjivi05 1ff9492220 l10n: keep a percent sign that is not a placeholder from being read as one in translations 2026-10-04 22:54:43 -04:00
maxjivi05 da5613a34a l10n: translate the last strings and name the Play button as each language does 2026-10-04 22:51:02 -04:00
maxjivi05 3515d2495a l10n: drop two strings nothing shows any more 2026-10-04 22:50:13 -04:00
maxjivi05 6b646de9e3 l10n: localize the remaining UI text (#212)
Text that still reached the screen in English because its function had no
Context now comes from resources: the child-process limit's titles and
instructions, the update blocks and install failures, game save and storage
errors, game file export errors, component package errors, the GPU's support
line, family and unnamed fallback, and the driver source tags. The setting
choice lists and screen effect Looks use their localized labels, the second
library menu is built from separate resources instead of splitting a label at
its separators, and an account without a name gets whole sentences rather than
a stand-in name. The device report keeps its English text.
2026-10-04 22:49:13 -04:00
maxjivi05 3df5a7f807 Rail: shrink a label that does not fit rather than cut it off 2026-10-04 22:45:24 -04:00
maxjivi05 2951c88b96 l10n: translate every string into Spanish, Japanese, Korean and Chinese (Simplified, Taiwan, Hong Kong)
Fills the 600-700 strings each language was missing, including the text just moved out of the
code (#212), and corrects existing translations that had lost a sentence, inverted a meaning or
broken a placeholder. Hong Kong Chinese now uses Hong Kong terms throughout rather than mainland
ones in traditional characters. Plurals carry every quantity each language needs.
2026-10-04 22:45:24 -04:00
maxjivi05 aece5b9bcb l10n: drive loading and install stages from step IDs, not their text
The loading screen's checklist, the session's package lines and the Decky
plugin button branched on the English wording of progress lines that are now
translated. They now carry what the line is about beside it: the installer's
Step, a LoadingState topic and readable flag (the session script's English
"== STEP" lines are still classified by their text), and DeckyManager's
downloadingBinary. English behaviour is unchanged.

Refs #212
2026-10-04 22:44:10 -04:00
maxjivi05 51e3423f58 l10n: keep every English string in strings.xml 2026-10-04 22:35:59 -04:00
maxjivi05 ccb726a9f2 Steam language: pass the app's language to the session 2026-10-04 22:35:59 -04:00
maxjivi05 44030cac0c Merge branch 'wip/l10n-g3' into feat/language-selection 2026-10-04 22:34:43 -04:00
maxjivi05 bd458667db Merge branch 'wip/l10n-g4' into feat/language-selection 2026-10-04 22:34:40 -04:00
maxjivi05 4fff23a47f Steam language: set the client's language from the app's before every start 2026-10-04 22:34:40 -04:00
maxjivi05 59010f1def l10n: move runtime, update and core manager UI text into string resources (#212) 2026-10-04 22:34:18 -04:00
maxjivi05 94bae6ea52 l10n: move session, GPU and display UI text into string resources (#212) 2026-10-04 22:33:23 -04:00
maxjivi05 bee4f82485 Merge branch 'wip/l10n-g2' into feat/language-selection 2026-10-04 22:33:09 -04:00
maxjivi05 7bb07d5dba Steam language: a guest helper that sets the client's language before it starts 2026-10-04 22:33:09 -04:00
maxjivi05 88716f756d l10n: move ui, files, input, store and frontend UI text into string resources (#212) 2026-10-04 22:32:19 -04:00
maxjivi05 9c72e3daf7 Tests: let Robolectric read the app's string resources 2026-10-04 22:31:48 -04:00
maxjivi05 fba2f378be Merge branch 'wip/l10n-g1' into feat/language-selection 2026-10-04 22:30:42 -04:00
maxjivi05 0fc6ccb6f4 Language setting: name the system language without its region 2026-10-04 22:30:42 -04:00
maxjivi05 2422cc9fef l10n: move launcher, session and component menu UI text into string resources (#212) 2026-10-04 22:30:07 -04:00
maxjivi05 1addb5c848 Guest fonts: give the runtime Android's fonts for Chinese, Japanese, Korean and other scripts (#118, #206) 2026-10-04 22:24:49 -04:00
輕鈴 b3ec65f41b Refine Simplified Chinese localization 2026-10-04 22:23:32 -04:00
maxjivi05 1560817716 Language setting: follow the system by default, or a language picked in Setup 2026-10-04 22:20:51 -04:00
99 changed files with 10458 additions and 1674 deletions

No files matched your search

+2
View File
@@ -165,6 +165,8 @@ android {
testOptions {
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
unitTests.returnDefaultValues = true
// Robolectric tests start activities whose text is now all string resources.
unitTests.includeAndroidResources = true
}
compileOptions {
@@ -11,6 +11,14 @@ import com.droiddeck.launcher.session.GpuClockPin
* sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun attachBaseContext(base: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
com.droiddeck.launcher.core.AppLanguage.systemChanged(newConfig)
}
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
@@ -63,7 +63,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
val message = runCatching(work).getOrElse { e -> activity.getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
ui.post {
compBusy = null
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
@@ -79,7 +79,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
@@ -91,8 +91,8 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
activity.getString(R.string.comp_stored, pkg.version)
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
@@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
/** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */
var deckyDownloadingBinary by mutableStateOf(false)
var deckySupervisor by mutableStateOf(false)
var deckyReleaseRequest = 0
@@ -41,11 +43,11 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
}.getOrElse { error -> activity.getString(R.string.decky_install_failed, error.message ?: error.javaClass.simpleName) }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
@@ -55,16 +57,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = "Starting plugin import…"; deckyPercent = -1
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.installPluginZip(activity, archive) { label, value ->
ui.post { deckyStage = label; deckyPercent = value }
val binary = DeckyManager.downloadingBinary
ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary }
}
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
}.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) }
ui.post {
deckyStage = null; deckyPercent = -1
val message = problem ?: "Plugin installed. Restart Steam to load it."
deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false
val message = problem ?: activity.getString(R.string.decky_plugin_installed)
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "import-decky-plugin").start()
@@ -37,7 +37,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
// Empty until the first refresh: the activity has no context yet while its fields are made.
var releaseStatus by mutableStateOf("")
var releaseChecking by mutableStateOf(false)
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
@@ -57,10 +58,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
private var autoCheckedThisProcess = false
fun state() = GpuDriversState(
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
autoStatus = autoStatus, releaseStatus = releaseStatus.ifEmpty { activity.getString(R.string.drivers_not_checked) }, checking = releaseChecking,
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
activeBundle = activeBundle,
@@ -73,10 +74,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
DriverRow(id, it.label, activity.getString(if (linux) R.string.drivers_bundle_sets_display else R.string.drivers_bundle_sets_runtime), true, DriverRow.BUNDLE)
}
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
if (id.isEmpty()) DriverRow("", activity.getString(R.string.gpu_runtime_default), activity.getString(R.string.drivers_runtime_builtin), false)
else bundleRow(id, linux = true) ?: DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
@@ -92,8 +93,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
TurnipDriver.AUTO, activity.getString(R.string.gpu_auto_by_gpu),
if (auto == "system") activity.getString(R.string.drivers_system_vulkan) else activity.getString(R.string.drivers_bundled, td.displayName(auto)),
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
@@ -109,7 +110,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
fun summary(): String {
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
}
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
@@ -125,7 +127,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val mb = p.assets.sumOf { it.size } / 1_048_576.0
PairRow(
key = p.key, name = p.name, version = p.version,
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
detail = if (!p.complete) activity.getString(R.string.drivers_half_published) else activity.getString(R.string.drivers_pair_size, mb),
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
installed = displayId != null && linuxId != null,
active = displayId != null && linuxId != null &&
@@ -153,11 +155,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
return
}
releaseChecking = true
autoStatus = "Checking for the latest drivers…"
autoStatus = activity.getString(R.string.drivers_checking_latest)
Thread({
val cached = TurnipReleases.cached(activity)
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
@@ -172,9 +174,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
row?.active == true -> autoStatus = problem?.let { activity.getString(R.string.drivers_up_to_date_check_failed, it) }
?: activity.getString(R.string.upd_current_status)
pair == null || !pair.complete ->
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
else -> installPair(pair, auto = true)
}
}
@@ -197,7 +200,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (!pair.complete) return
pairBusy = pair.key
pairPercent = 0
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
Thread({
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
@@ -217,7 +220,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
val restart = displayChanged && CompositorHost.isStarted
if (auto) {
val previous = SessionPrefs.gpuAutoInstalled(activity)
for (id in previous - setOf(displayId, linuxId)) {
@@ -230,14 +233,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
TurnipReleases.forget(activity, id)
}
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
autoStatus = activity.getString(
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
pair.name, pair.version,
)
} else {
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(activity, activity.getString(if (restart) R.string.drivers_using_restart else R.string.drivers_using, pair.name, pair.version), android.widget.Toast.LENGTH_LONG).show()
}
}.onFailure { e ->
Log.w(TAG, "driver pair ${pair.key}", e)
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
if (auto) autoStatus = why ?: "Download failed"
val why = if (e is IllegalArgumentException) e.message else activity.getString(R.string.comp_download_failed, e.message)
if (auto) autoStatus = why ?: activity.getString(R.string.user_apps_download_failed)
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
}
refreshDrivers()
@@ -298,7 +305,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
try {
LinuxVulkanDriverManager(activity).installDriver(uri, name)
} catch (runtime: IllegalArgumentException) {
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
throw IllegalArgumentException(activity.getString(R.string.drivers_not_a_driver))
}
}
}
@@ -307,10 +314,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
activity.getString(R.string.drivers_import_failed, e.message)
}
ui.post {
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
val done = bundle?.let { useBundle(it) }
?: name?.let { activity.getString(R.string.comp_imported, it) } ?: activity.getString(R.string.drivers_imported_unnamed)
android.widget.Toast.makeText(
activity, problem ?: done,
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
@@ -340,8 +348,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
DriverBundle.select(activity, bundle)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
return "Using ${bundle.label} as both the runtime and display driver." +
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
return activity.getString(
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
bundle.label,
)
}
/**
@@ -354,7 +364,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (bundle != null) {
DriverBundle.remove(activity, bundle)
TurnipReleases.forget(activity, bundle.id)
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted_bundle, bundle.label), android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
return
}
@@ -367,7 +377,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
}
TurnipReleases.forget(activity, id)
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted, id), android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
@@ -378,26 +388,28 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
.filter { it.bundle || it.linux == linux }
.filter { a -> installedIdOf(a) == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
val mb = activity.getString(R.string.common_size_mb, a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
null -> activity.getString(R.string.drivers_not_checked)
else -> {
val latest = check.latest.joinToString(" · ") { "${it.first} ${it.second}" }
if (check.failed.isEmpty()) activity.getString(R.string.drivers_release_status, latest, ago(check.checkedAt))
else activity.getString(R.string.drivers_release_status_failed, latest, check.failed.joinToString(), ago(check.checkedAt))
}
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
m < 1 -> activity.getString(R.string.upd_just_now)
m < 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_minutes, m.toInt(), m)
m < 48 * 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_hours, (m / 60).toInt(), m / 60)
else -> activity.resources.getQuantityString(R.plurals.drivers_ago_days, (m / (24 * 60)).toInt(), m / (24 * 60))
}
}
@@ -405,16 +417,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
releaseStatus = activity.getString(R.string.drivers_checking_sources)
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
Log.w(TAG, "latest Turnip check", e); e.message ?: activity.getString(R.string.drivers_check_failed)
}
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
if (problem != null) releaseStatus = "Couldn't check: $problem"
if (problem != null) releaseStatus = activity.getString(R.string.upd_couldnt_check, problem)
}
}, "turnip-release-check").start()
}
@@ -433,13 +445,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
activity.getString(R.string.comp_download_failed, e.message)
}
ui.post {
releaseProgress.remove(assetName)
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
val installed = activity.getString(
if (asset.bundle) R.string.drivers_installed_pick_either else R.string.drivers_installed_pick,
asset.name.removeSuffix(".zip"),
)
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
activity, problem ?: installed,
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
@@ -7,6 +7,11 @@ import com.droiddeck.launcher.session.SessionService
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
class HomeActivity : Activity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeHome()
@@ -81,6 +81,11 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
* the store, downloads, settings - is the client's own job once [SessionActivity] has it on screen.
*/
class MainActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private val ui = Handler(Looper.getMainLooper())
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
@@ -104,7 +109,7 @@ class MainActivity : ComponentActivity() {
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
@@ -251,29 +256,32 @@ class MainActivity : ComponentActivity() {
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction("Importing into $name") {
val (written, backup) = GameSaves.import(game(), zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
saveAction(getString(R.string.main_saves_importing, name)) {
val (written, backup) = GameSaves.import(this, game(), zip)
val kind = GameSaves.layoutOf(zip)?.label(this) ?: getString(R.string.saves_zip)
resources.getQuantityString(
if (backup != null) R.plurals.main_saves_imported_backup else R.plurals.main_saves_imported,
written, written, kind, name,
)
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), getString(R.string.main_saves_pick_zip, name), GameSaves.savesDir().parentFile?.parentFile?.path))
}
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction("Exporting $name") {
val (zip, count) = GameSaves.export(game(), layout, dir)
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
saveAction(getString(R.string.main_saves_exporting, name)) {
val (zip, count) = GameSaves.export(this, game(), layout, dir)
resources.getQuantityString(R.plurals.main_saves_exported, count, count, layout.label(this), zip.path.removePrefix("/storage/emulated/0/"))
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
@@ -324,7 +332,7 @@ class MainActivity : ComponentActivity() {
private var renderer by mutableStateOf("vulkan")
private var gameStorage by mutableStateOf("")
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
}
@@ -355,15 +363,15 @@ class MainActivity : ComponentActivity() {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
busy || LinuxRuntimeInstaller.isBusy() -> {
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
return
}
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_proton_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
pkgStage != null -> {
android.widget.Toast.makeText(this, "Wait for the desktop app install to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
}
@@ -399,7 +407,7 @@ class MainActivity : ComponentActivity() {
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(this, folder, game)
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
getString(R.string.game_file_exported, folder.path)
@@ -434,7 +442,7 @@ class MainActivity : ComponentActivity() {
private fun launchGame(game: Library.SteamGame): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (SessionState.running) {
@@ -481,6 +489,7 @@ class MainActivity : ComponentActivity() {
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
@@ -518,6 +527,7 @@ class MainActivity : ComponentActivity() {
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -627,7 +637,7 @@ class MainActivity : ComponentActivity() {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
ui.post {
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
}, "share-logs").start()
@@ -637,7 +647,7 @@ class MainActivity : ComponentActivity() {
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
ui.post {
android.widget.Toast.makeText(this,
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
if (cleared == 0) getString(R.string.main_no_logs_to_clear) else resources.getQuantityString(R.plurals.main_logs_cleared, cleared, cleared),
android.widget.Toast.LENGTH_SHORT).show()
}
}, "clear-logs").start()
@@ -648,6 +658,13 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
// Redrawn in the new language; Setup's page and tab are saved state.
recreate()
}
},
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
@@ -680,7 +697,7 @@ class MainActivity : ComponentActivity() {
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
@@ -693,7 +710,7 @@ class MainActivity : ComponentActivity() {
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -703,7 +720,7 @@ class MainActivity : ComponentActivity() {
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -712,9 +729,9 @@ class MainActivity : ComponentActivity() {
},
onCopyPhantomCommand = { enabled ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.adbCommand(enabled)),
ClipData.newPlainText(getString(R.string.main_adb_clip_label), PhantomProcessLimit.adbCommand(enabled)),
)
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.main_adb_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
@@ -748,24 +765,24 @@ class MainActivity : ComponentActivity() {
path = romsDir,
onChoose = {
showRoms = false
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the ROMs folder", romsDir))
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_roms), romsDir))
},
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
onDismiss = { showRoms = false },
)
showNonAdreno?.let { release ->
ConfirmDialog(
title = "Not an Adreno GPU",
text = "Turnip supports Adreno GPUs. On ${com.droiddeck.launcher.core.DeviceSupport.gpuName()}, Steam may show a black screen. Download: ${"%.0f".format(release.size / 1e6)} MB.",
confirm = "Install anyway",
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
onConfirm = { showNonAdreno = null; install(release) },
onDismiss = { showNonAdreno = null },
)
}
if (showRemove) ConfirmDialog(
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
confirm = "Remove",
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
confirm = androidx.compose.ui.res.stringResource(R.string.store_remove),
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
@@ -875,7 +892,7 @@ class MainActivity : ComponentActivity() {
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post { if (showComponents) components.refreshComponents() }
}, "components-queue").start()
@@ -934,9 +951,13 @@ class MainActivity : ComponentActivity() {
try {
HomeApp.launch(this, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
android.widget.Toast.makeText(this, "Could not open ${app.label} on $target", android.widget.Toast.LENGTH_SHORT).show()
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
}
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
}
}
@@ -967,7 +988,7 @@ class MainActivity : ComponentActivity() {
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
val message = runCatching(work).getOrElse { e -> getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
@@ -1013,10 +1034,10 @@ class MainActivity : ComponentActivity() {
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction("Importing") {
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
}
}
@@ -1034,14 +1055,14 @@ class MainActivity : ComponentActivity() {
requestInitialFocus = focusComponentsContent,
onProton = { components.chooseProton(it) },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_swapping)) { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_restoring)) { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_cancelling)) { ComponentsManager.cancelQueued(this, pid, components.compComp); getString(R.string.comp_swap_cancelled) } } },
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
@@ -1054,9 +1075,9 @@ class MainActivity : ComponentActivity() {
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_linux))) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
)
@@ -1092,14 +1113,14 @@ class MainActivity : ComponentActivity() {
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
pkgId = id; pkgStage = getString(R.string.store_starting); pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, "${entry.name}: $problem", android.widget.Toast.LENGTH_LONG).show()
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, entry.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
@@ -1109,7 +1130,7 @@ class MainActivity : ComponentActivity() {
private fun removePackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
pkgId = id; pkgStage = getString(if (entry.kind == "appimage") R.string.main_removing_named else R.string.main_forgetting_named, entry.name); pkgPercent = -1
Thread({
DesktopCatalog.remove(this, entry)
ui.post {
@@ -1163,6 +1184,7 @@ class MainActivity : ComponentActivity() {
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
@@ -1211,7 +1233,7 @@ class MainActivity : ComponentActivity() {
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
onGameStorage = { path, label -> setGameStorage(path, label) },
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
},
onStorageDiagnostics = { on ->
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
@@ -1237,13 +1259,13 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
onPickAddedGameExe = { folder ->
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
},
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
@@ -1254,7 +1276,7 @@ class MainActivity : ComponentActivity() {
decky.deckySupervisor = false
},
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = { settingsMode = null },
),
@@ -1373,7 +1395,7 @@ class MainActivity : ComponentActivity() {
Thread({
drivers.refreshDrivers()
val games = scanAddedGames()
val storage = GameStorage.options(this).map { it.label to it.path }
val storage = GameStorage.options(this)
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
ui.post {
@@ -1392,9 +1414,9 @@ class MainActivity : ComponentActivity() {
/** A second Steam library, proven writable first; "" = internal only. */
private fun setGameStorage(path: String, label: String) {
if (path.isNotEmpty() && path != SessionPrefs.GAME_STORAGE_OFF) {
val problem = GameStorage.prepare(path)
val problem = GameStorage.prepare(this, path)
if (problem != null) {
android.widget.Toast.makeText(this, "Not usable: $problem", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, problem), android.widget.Toast.LENGTH_LONG).show()
return
}
}
@@ -1436,9 +1458,9 @@ class MainActivity : ComponentActivity() {
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "Desktop"
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: getString(R.string.main_running_program)
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
@@ -1505,7 +1527,10 @@ class MainActivity : ComponentActivity() {
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
getString(
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
PhantomProcessLimit.instructions(this, phantomProcessStatus), PhantomProcessLimit.adbCommand(),
)
} else null
}
@@ -1521,10 +1546,10 @@ class MainActivity : ComponentActivity() {
} catch (error: Exception) {
if (displayId != null) {
runCatching { startActivity(intent) }
android.widget.Toast.makeText(this, "Could not open Settings on the bottom screen; opened it on the main screen.", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_settings_main_screen, android.widget.Toast.LENGTH_LONG).show()
} else {
startActivity(Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
android.widget.Toast.makeText(this, "Open Developer options in Settings", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, R.string.main_open_dev_options, android.widget.Toast.LENGTH_LONG).show()
}
}
}
@@ -1557,7 +1582,7 @@ class MainActivity : ComponentActivity() {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -24,7 +24,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = "Starting…"
protonStage = activity.getString(R.string.store_starting)
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
@@ -44,7 +44,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonStage = activity.getString(R.string.main_removing_named, tool.name)
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
@@ -100,6 +100,11 @@ import kotlin.math.abs
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
@@ -301,7 +306,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
finish()
return
}
@@ -462,6 +467,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
topic = loading.topic, readable = loading.readable,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
@@ -497,7 +503,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
@@ -507,7 +513,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
@@ -568,9 +574,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
}
Toast.makeText(this@SessionActivity, message, Toast.LENGTH_SHORT).show()
}
},
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
@@ -688,7 +698,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
@@ -703,10 +713,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
ComponentsManager.cancelQueued(this, protonId, comp); getString(R.string.comp_swap_cancelled)
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
}.getOrElse { e -> getString(R.string.session_swap_failed, e.message ?: e.javaClass.simpleName) }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
@@ -720,20 +730,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
private fun pausedTitle(): String = when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "The desktop is paused"
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
else -> "Steam is paused"
SessionService.MODE_DESKTOP -> getString(R.string.session_desktop_paused)
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
?.let { getString(R.string.session_named_paused, it) } ?: getString(R.string.session_program_paused)
else -> getString(R.string.load_steam_paused)
}
private fun loadingTitle(): String = when (loadingMode()) {
SessionService.MODE_DESKTOP ->
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
else "Starting the desktop"
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
else getString(R.string.session_starting_desktop)
SessionService.MODE_RUN -> {
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
?: getString(R.string.session_starting_program)
}
else -> "Starting Steam"
else -> getString(R.string.load_starting_steam)
}
private fun shareCurrentSessionLogs() {
@@ -741,7 +753,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// ended screen's Share logs is for that one.
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
if (folder == null || !folder.isDirectory) {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_no_logs, Toast.LENGTH_LONG).show()
return
}
Thread({
@@ -750,12 +762,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
.getOrNull()
uiHandler.post {
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
Toast.makeText(this, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
}
}
}
@@ -814,8 +826,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
else "downloading Proton Experimental (ARM64)"
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -856,7 +868,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
loading.percent = -1
loading.step = "Starting the session…"
loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false)
// The surface may have come and gone while the download ran; start on the live one.
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
}
@@ -872,14 +884,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "session-failure-artifacts").start()
}
/**
* The loading screen's lines for one package: downloading it (with and without its size), checking it,
* unpacking it; the checklist [topic] they belong to, and whether the checking line is shown as it is.
*/
private class PackageLines(
val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int,
val topic: LoadingState.Topic, val checkingReadable: Boolean,
)
/** Reports one package's download on the loading screen: "<what> · 332 of 791 MB", checking, unpacking. */
private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
uiHandler.post {
loading.percent = p
loading.step = when {
stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) "downloading $what · ${p * mb / 100} of $mb MB" else "downloading $what"
stage.startsWith("Verifying") -> "checking $what"
else -> "unpacking $what"
private fun progressFor(what: PackageLines, mb: Long) = object : com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) =
onProgress(com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
uiHandler.post {
loading.percent = percent
when (step) {
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.DOWNLOADING -> loading.say(
if (percent >= 0 && mb > 0) getString(what.downloadingMb, percent * mb / 100, mb) else getString(what.downloading),
what.topic, readable = true,
)
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.VERIFYING ->
loading.say(getString(what.checking), what.topic, what.checkingReadable)
else -> loading.say(getString(what.unpacking), what.topic, readable = true)
}
}
}
}
@@ -887,29 +917,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Null when the runtime is in, else the loading screen's closing line. */
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
?: return getString(R.string.session_runtime_catalog_unreachable)
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor("the Linux runtime", release.size / 1_000_000))
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
}
/** Null when the desktop package is in, else the loading screen's closing line. */
private fun installDesktop(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading the desktop" }
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
?: return "Could not reach the desktop catalog. Check the connection and press Desktop again."
?: return getString(R.string.session_desktop_catalog_unreachable)
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
progressFor("the desktop", entry.size / 1_000_000))
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
}
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
?: return "catalog unreachable"
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
return catalog.install(this, entry, progressFor(PROTON_LINES, entry.size / 1_000_000))
}
override fun surfaceCreated(holder: SurfaceHolder) {
@@ -1169,14 +1199,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
val stopped = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program)
?.let { getString(R.string.session_ended_named, it) } ?: getString(R.string.session_ended_program)
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
loading.showEnded(
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
)
focusEndedScreen()
}
@@ -1226,8 +1257,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
} else if (enosys >= 8) {
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
EndHint(resources.getQuantityString(R.plurals.session_hint_enosys, enosys, enosys,
getString(R.string.perf_title), getString(R.string.perf_seccomp), getString(R.string.perf_xalia)))
} else null
} catch (e: Exception) {
null
@@ -2088,5 +2119,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Compositor scale modes (Container.FULLSCREEN_* values): 1 = fit with bars, centred. */
private const val SCALE_FIT = 1
private const val ALIGN_CENTER = 0
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
LoadingState.Topic.RUNTIME, checkingReadable = true,
)
private val DESKTOP_LINES = PackageLines(
R.string.session_desktop_downloading, R.string.session_desktop_downloading_mb,
R.string.session_desktop_checking, R.string.session_desktop_unpacking,
LoadingState.Topic.DESKTOP, checkingReadable = false,
)
private val PROTON_LINES = PackageLines(
R.string.session_proton_downloading, R.string.session_proton_downloading_mb,
R.string.session_proton_checking, R.string.session_proton_unpacking,
LoadingState.Topic.STEAM, checkingReadable = false,
)
}
}
@@ -46,7 +46,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
onAllowInstalls = {
askPermission = false
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
},
onDismissPermission = { askPermission = false; pending = null },
)
@@ -77,7 +77,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
catalog = it
follow = AppUpdates.follow(activity, it)
error = null
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
}.onFailure { if (force) error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
}
}, "app-updates").start()
}
@@ -90,20 +90,20 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
private fun install(r: AppUpdates.Release) {
if (stage != null) return
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
AppUpdates.installBlock(r)?.let { error = it; return }
if (SessionState.running) { error = "Stop the running session first, then update"; return }
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
error = null
stage = "Downloading"
stage = activity.getString(R.string.upd_stage_downloading)
percent = 0
Thread({
val result = runCatching {
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
ui.post { stage = "Installing"; percent = -1 }
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
SelfInstaller.install(activity, file)
}
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
}, "app-update-install").start()
}
@@ -114,14 +114,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
stage = "Waiting for you to confirm"
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
stage = activity.getString(R.string.upd_stage_confirm)
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
PackageInstaller.STATUS_SUCCESS -> stage = null
else -> {
stage = null
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
error = SelfInstaller.describeFailure(activity, status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
}
}
}
@@ -0,0 +1,127 @@
package com.droiddeck.launcher.core
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.LocaleList
import java.util.Locale
/**
* The language the app shows itself in.
*
* Nothing chosen (the default) means the system's: Android's own language, or the per-app language
* Android 13+ lets a user set for DroidDeck in its settings, exactly as Android resolves it. Picking
* one in Setup overrides that until the user picks "System default" again. Each activity and service
* applies the choice to its own context ([wrap], [applyTo]), so it works on every Android version
* the app runs on rather than only where the platform's per-app language API exists.
*
* Steam follows the same choice: [SteamLanguage] turns [effective] into the client's language.
*/
object AppLanguage {
/** The stored value meaning "follow the system". */
const val SYSTEM = ""
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
val supported: List<String> = listOf("en", "es", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
private const val PREFS = "language"
private const val KEY = "app_language"
/** The locale Android gave the process before any choice here was applied. */
@Volatile private var systemLocale: Locale? = null
/** The stored choice: one of [supported], or [SYSTEM]. */
fun chosen(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM)
?.takeIf { it in supported } ?: SYSTEM
fun setChosen(context: Context, tag: String) {
val value = if (tag in supported) tag else SYSTEM
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, value).commit()
val locale = value.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
Locale.setDefault(locale)
// Code that reads strings through the application context sees the new language too.
val app = context.applicationContext
@Suppress("DEPRECATION")
app.resources.updateConfiguration(
Configuration(app.resources.configuration).apply { setLocales(LocaleList(locale)) },
app.resources.displayMetrics,
)
}
/** The locale Android chose for the app, ignoring the choice made here. */
fun system(context: Context): Locale =
systemLocale ?: context.resources.configuration.locales[0] ?: Locale.getDefault()
/** The locale the app is shown in: the chosen language, or else the system's. */
fun effective(context: Context): Locale =
chosen(context).takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
/** Remembers the system's locale; call with the untouched base context, before [wrap]. */
fun noteSystem(base: Context) {
if (systemLocale == null) systemLocale = base.resources.configuration.locales[0]
}
/**
* [base], showing the chosen language: for an Application's or a Service's attachBaseContext.
* Only the locale is overridden, so screen size, density and night mode keep following the
* device as they change.
*/
fun wrap(base: Context): Context {
noteSystem(base)
val tag = chosen(base).ifEmpty { return base }
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
return base.createConfigurationContext(override(locale))
}
/**
* Applies the chosen language to an activity; call from its attachBaseContext, after super.
* applyOverrideConfiguration keeps the locale through rotation and window resizes, which an
* activity here handles itself rather than being recreated.
*/
fun applyTo(activity: Activity, base: Context) {
noteSystem(base)
val tag = chosen(base).ifEmpty { return }
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
activity.applyOverrideConfiguration(override(locale))
}
/** Follows a change of the system's language while the process lives (Application.onConfigurationChanged). */
fun systemChanged(config: Configuration) {
config.locales[0]?.let { systemLocale = it }
}
// A new Configuration's fontScale is 1, which as an override would undo the user's font size;
// 0 leaves it undefined, so only the locale is changed.
private fun override(locale: Locale) = Configuration().apply { fontScale = 0f; setLocales(LocaleList(locale)) }
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
fun supportedTag(locale: Locale): String? = when (locale.language) {
"en", "es", "ja", "ko" -> locale.language
"zh" -> when {
locale.script == "Hans" -> "zh-CN"
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
locale.script == "Hant" || locale.country == "TW" -> "zh-TW"
else -> "zh-CN"
}
else -> null
}
/** [locale]'s language named in itself, without its region unless the app tells them apart. */
fun displayName(locale: Locale): String =
supportedTag(locale)?.let(::nativeName) ?: locale.getDisplayLanguage(locale).replaceFirstChar { it.titlecase(locale) }
/** A language's name in that language, for the picker: what a reader of it looks for. */
fun nativeName(tag: String): String = when (tag) {
"en" -> "English"
"es" -> "Español"
"ja" -> "日本語"
"ko" -> "한국어"
"zh-CN" -> "简体中文"
"zh-TW" -> "繁體中文(台灣)"
"zh-HK" -> "繁體中文(香港)"
else -> Locale.forLanguageTag(tag).let { it.getDisplayName(it) }
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.os.Build
import com.droiddeck.launcher.R
import java.io.File
/**
@@ -17,8 +19,8 @@ object DeviceSupport {
File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
/** The chip as the device names it, for the card that explains the refusal. */
fun gpuName(): String {
fun gpuName(context: Context): String {
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { "this GPU" }
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
}
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.os.Build
import android.provider.Settings
import com.droiddeck.launcher.R
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
enum class PhantomProcessStatus {
@@ -98,29 +99,30 @@ object PhantomProcessLimit {
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
fun title(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
fun title(context: Context, status: PhantomProcessStatus): String = context.getString(when (status) {
PhantomProcessStatus.ENABLED -> R.string.phantom_title_on
PhantomProcessStatus.UNSET -> R.string.phantom_title_unset
PhantomProcessStatus.UNREADABLE -> R.string.phantom_title_unreadable
PhantomProcessStatus.DISABLED -> R.string.phantom_title_off
PhantomProcessStatus.NOT_APPLICABLE -> R.string.phantom_title_not_required
})
fun instructions(context: Context, status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE ->
context.getString(R.string.phantom_instructions_fix, fixSentence(context))
PhantomProcessStatus.DISABLED -> context.getString(R.string.phantom_instructions_off)
PhantomProcessStatus.NOT_APPLICABLE -> context.getString(R.string.phantom_instructions_not_applicable)
}
fun instructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
fun gateInstructions(context: Context, status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence(context)
else -> instructions(context, status)
}
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
fun fixSentence(context: Context, sdk: Int = Build.VERSION.SDK_INT): String =
context.getString(if (hasDeveloperToggle(sdk)) R.string.phantom_fix_toggle else R.string.phantom_fix_no_toggle)
/** English: for the device report. */
fun reportValue(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import com.droiddeck.launcher.R
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
@@ -37,21 +38,21 @@ object WirelessAdbFix {
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
KadbCert.set(identity.first, identity.second)
writeAtomically(cert, identity.first)
writeAtomically(key, identity.second)
writeAtomically(context, cert, identity.first)
writeAtomically(context, key, identity.second)
}
private fun writeAtomically(destination: File, contents: ByteArray) {
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
val temporary = File(destination.parentFile, "${destination.name}.tmp")
temporary.writeBytes(contents)
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
check(temporary.renameTo(destination)) { context.getString(R.string.adbfix_save_failed) }
}
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
loadOrCreateIdentity(context.applicationContext)
Kadb.pair(host, port, pairingCode, "DroidDeck")
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
writeAtomically(context, File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
writeAtomically(context, File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
}
private fun savedConnection(context: Context): SavedConnection? {
@@ -73,6 +74,7 @@ object WirelessAdbFix {
private fun saveConnection(context: Context, host: String, port: Int) {
writeAtomically(
context,
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
"$host\n$port".toByteArray(),
)
@@ -171,11 +173,11 @@ object WirelessAdbFix {
Kadb.create(host, port).use { adb ->
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
check(change.exitCode == 0) { change.allOutput.ifBlank { context.getString(R.string.adbfix_command_failed, change.exitCode) } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
context.getString(R.string.adbfix_not_confirmed, result.allOutput.trim())
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
@@ -186,7 +188,7 @@ object WirelessAdbFix {
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
val appContext = context.applicationContext
val saved = savedConnection(appContext)
?: error("No Wireless debugging pairing is saved")
?: error(appContext.getString(R.string.adbfix_no_pairing))
val cachedPort = saved.port
if (cachedPort != null) {
val cachedResult = runCatching {
@@ -201,7 +203,7 @@ object WirelessAdbFix {
}
} else {
val discoveredPort = findConnectPort(appContext, saved.host)
?: error("Enter the current Wireless debugging IP address & Port")
?: error(appContext.getString(R.string.adbfix_enter_address))
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
}
}
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
@@ -35,7 +38,7 @@ class WirelessAdbPairingService : Service() {
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
private val timeout = Runnable { finish(Stage.Failed(getString(R.string.adbpair_timed_out))) }
override fun onBind(intent: Intent?): IBinder? = null
@@ -75,13 +78,13 @@ class WirelessAdbPairingService : Service() {
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
main.post { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed_code, errorCode))) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
.onFailure { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed, it.localizedMessage.toString()))) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
@@ -107,34 +110,34 @@ class WirelessAdbPairingService : Service() {
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
show(Stage.CodeNeeded(getString(R.string.adbpair_six_digits)))
return
}
working = true
show(Stage.Working("Pairing…"))
show(Stage.Working(getString(R.string.adbpair_pairing)))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
show(Stage.CodeNeeded(getString(R.string.adbpair_code_rejected)))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
show(Stage.Working(getString(R.string.adbpair_connecting)))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
?: error(getString(R.string.adbpair_port_not_found))
main.post { show(Stage.Working(getString(R.string.adbpair_applying))) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: getString(R.string.adbpair_command_failed)) } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
@@ -174,9 +177,9 @@ class WirelessAdbPairingService : Service() {
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.adbpair_channel_name),
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
description = getString(R.string.adbpair_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -193,41 +196,40 @@ class WirelessAdbPairingService : Service() {
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setContentTitle(getString(R.string.adbpair_title_pair))
.setContentText(getString(R.string.adbpair_waiting_text))
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_waiting_long)))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
val input = RemoteInput.Builder(KEY_CODE).setLabel(getString(R.string.adbpair_code_label)).build()
val text = stage.error ?: getString(R.string.adbpair_code_hint)
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentTitle(getString(if (stage.error == null) R.string.adbpair_title_code else R.string.adbpair_title_retry))
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
.addAction(Notification.Action.Builder(null, getString(R.string.adbpair_enter_code), reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentTitle(getString(R.string.adbpair_title_working))
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setContentTitle(getString(R.string.adbpair_title_done))
.setContentText(getString(R.string.adbpair_done_text))
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_done_text)))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentTitle(getString(R.string.adbpair_title_failed))
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
@@ -56,6 +56,8 @@ data class RailItem(
val selected: Boolean,
/** >0 shows a small accent count badge on the item's icon (expanded and collapsed). */
val badge: Int = 0,
/** The under-icon label when the rail is collapsed, for names too long to fit; null derives one. */
val shortLabel: String? = null,
// onClick stays LAST so existing call sites can pass it as a trailing lambda; badge is an
// optional named param before it (default 0).
val onClick: () -> Unit,
@@ -73,7 +75,6 @@ private fun collapsedLabel(label: String): String {
when (label) {
"ENVIROMENT" -> return "ENVIRON" // the app's existing (mis)spelling, kept uppercase
"WIN COMPONENTS" -> return "WIN COMP"
"Downloads" -> return "Downlds"
}
if (label.contains(' ')) return label // multi-word → wraps to 2 lines
return if (label.length > 9) label.take(8) + "…" else label
@@ -317,7 +318,7 @@ fun CollapsibleRail(
RailItemIcon(item, iconTint)
Spacer(Modifier.height(2.dp))
Text(
collapsedLabel(item.label),
item.shortLabel ?: collapsedLabel(item.label),
color = labelColor,
fontSize = 7.5.sp,
lineHeight = 8.5.sp,
@@ -15,6 +15,11 @@ import java.io.File
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
class FileManagerActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
@@ -61,6 +61,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -107,7 +108,7 @@ private fun FileContextMenuItems(
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
text = { Text(stringResource(R.string.fm_select)) },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
@@ -115,7 +116,7 @@ private fun FileContextMenuItems(
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
text = { Text(stringResource(R.string.fm_properties)) },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
@@ -123,7 +124,7 @@ private fun FileContextMenuItems(
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
text = { Text(if (isFavorite) stringResource(R.string.fm_favorite_remove) else stringResource(R.string.fm_favorite_add)) },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
@@ -136,25 +137,25 @@ private fun FileContextMenuItems(
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.fm_rename)) },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.fm_copy)) },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
text = { Text(stringResource(R.string.fm_cut)) },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
text = { Text(stringResource(R.string.common_delete)) },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
@@ -184,6 +185,7 @@ internal fun FileItemRow(
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val context = LocalContext.current
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
@@ -276,7 +278,7 @@ internal fun FileItemRow(
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
if (!isDir) append(FileOps.formatBytes(context, file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -285,7 +287,7 @@ internal fun FileItemRow(
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
Icon(Icons.Filled.MoreVert, stringResource(R.string.fm_actions), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
@@ -338,7 +340,7 @@ internal fun FavoritesList(
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
text = stringResource(R.string.fm_favorites),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
@@ -351,7 +353,7 @@ internal fun FavoritesList(
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
text = stringResource(R.string.fm_pin_current),
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
@@ -366,7 +368,7 @@ internal fun FavoritesList(
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
text = stringResource(R.string.fm_no_favorites),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
@@ -431,7 +433,7 @@ private fun FavoriteCard(
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
text = stringResource(loc.driveLabel),
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
@@ -443,9 +445,9 @@ private fun FavoriteCard(
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
FavStorage.INTERNAL -> stringResource(R.string.games_internal)
FavStorage.SD -> stringResource(R.string.fm_drive_sd)
FavStorage.OTHER -> stringResource(R.string.fm_drive_storage)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
@@ -464,7 +466,7 @@ private fun FavoriteCard(
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
contentDescription = stringResource(R.string.fm_unpin_favorite),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
@@ -8,6 +8,8 @@ import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
@@ -77,7 +79,7 @@ enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
@StringRes val driveLabel: Int, // Internal, SD card, or Storage
val displayPath: String // the unix absolute path
)
@@ -87,15 +89,15 @@ fun describeLocation(file: File): FavLocation {
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
return FavLocation(FavStorage.INTERNAL, R.string.fm_drive_internal, abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
return FavLocation(FavStorage.SD, R.string.fm_drive_sd, abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
}
@@ -76,6 +76,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -261,11 +264,11 @@ fun FileManagerScreen(
fun performDelete(file: File) {
scope.launch {
isOperationRunning = true
operationLabel = "Deleting..."
operationLabel = context.getString(R.string.fm_op_deleting)
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
}
}
@@ -288,7 +291,7 @@ fun FileManagerScreen(
operationJob = scope.launch {
operationProgress = 0f
operationDeterminate = true
operationLabel = if (cut) "Moving..." else "Copying..."
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
var applyToAll: ConflictChoice? = null
@@ -324,10 +327,10 @@ fun FileManagerScreen(
}
// Progress is per item; with a batch the label carries the overall position.
operationLabel = buildString {
append(if (cut) "Moving" else "Copying")
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
append(" - ").append(src.name)
operationLabel = if (sources.size > 1) {
context.getString(if (cut) R.string.fm_op_moving_batch else R.string.fm_op_copying_batch, done + 1, sources.size, src.name)
} else {
context.getString(if (cut) R.string.fm_op_moving_item else R.string.fm_op_copying_item, src.name)
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
@@ -355,9 +358,9 @@ fun FileManagerScreen(
loadDirectory(currentDir, resetScroll = false)
val message = when {
failed > 0 -> "$done done, $failed failed"
skipped > 0 -> "$done done, $skipped skipped"
sources.size > 1 -> "$done items ${if (cut) "moved" else "copied"}"
failed > 0 -> context.getString(R.string.fm_paste_failed, done, failed)
skipped > 0 -> context.getString(R.string.fm_paste_skipped, done, skipped)
sources.size > 1 -> context.resources.getQuantityString(if (cut) R.plurals.fm_paste_moved else R.plurals.fm_paste_copied, done, done)
else -> null
}
if (message != null) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
@@ -367,32 +370,32 @@ fun FileManagerScreen(
fun performRename(file: File, newName: String) {
val target = File(file.parentFile, newName)
if (target.exists()) {
Toast.makeText(context, "\"$newName\" already exists", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
isOperationRunning = true
operationLabel = "Renaming..."
operationLabel = context.getString(R.string.fm_op_renaming)
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Rename failed", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
}
}
fun createFolder(parent: File, name: String) {
val target = File(parent, name)
if (target.exists()) {
Toast.makeText(context, "\"$name\" already exists", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
isOperationRunning = true
operationLabel = "Creating folder..."
operationLabel = context.getString(R.string.fm_op_creating_folder)
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
isOperationRunning = false
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, "Could not create folder", Toast.LENGTH_SHORT).show()
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
}
}
@@ -416,12 +419,12 @@ fun FileManagerScreen(
var folderName by remember { mutableStateOf("") }
OutlinedAlertDialog(
onDismissRequest = { showNewFolderDialog = false },
title = { Text("New Folder") },
title = { Text(stringResource(R.string.fm_new_folder)) },
text = {
OutlinedTextField(
value = folderName,
onValueChange = { folderName = it },
label = { Text("Folder name") },
label = { Text(stringResource(R.string.fm_folder_name)) },
singleLine = true,
)
},
@@ -429,9 +432,9 @@ fun FileManagerScreen(
TextButton(onClick = {
showNewFolderDialog = false
if (folderName.isNotBlank()) createFolder(currentDir, folderName)
}) { Text("Create") }
}) { Text(stringResource(R.string.fm_create)) }
},
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -439,12 +442,12 @@ fun FileManagerScreen(
var newName by remember(renameTarget) { mutableStateOf(renameTarget?.name ?: "") }
OutlinedAlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text("Rename") },
title = { Text(stringResource(R.string.fm_rename)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
label = { Text(stringResource(R.string.fm_new_name)) },
singleLine = true,
)
},
@@ -453,9 +456,9 @@ fun FileManagerScreen(
val file = renameTarget
renameTarget = null
if (file != null && newName.isNotBlank()) performRename(file, newName)
}) { Text("Rename") }
}) { Text(stringResource(R.string.fm_rename)) }
},
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -473,15 +476,15 @@ fun FileManagerScreen(
val file = selectedEntry ?: return
OutlinedAlertDialog(
onDismissRequest = { selectedEntry = null },
title = { Text("Delete?") },
text = { Text("Delete \"${file.name}\" permanently?") },
title = { Text(stringResource(R.string.fm_delete_title)) },
text = { Text(stringResource(R.string.fm_delete_text, file.name)) },
confirmButton = {
TextButton(onClick = {
selectedEntry = null
performDelete(file)
}) { Text("Delete") }
}) { Text(stringResource(R.string.common_delete)) }
},
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -489,10 +492,10 @@ fun FileManagerScreen(
val victims = pendingBulkDelete
OutlinedAlertDialog(
onDismissRequest = { pendingBulkDelete = emptyList() },
title = { Text("Delete ${victims.size} item${if (victims.size == 1) "" else "s"}?") },
title = { Text(pluralStringResource(R.plurals.fm_delete_items_title, victims.size, victims.size)) },
text = {
Column {
Text("This can't be undone.")
Text(stringResource(R.string.fm_cannot_undo))
Spacer(Modifier.height(6.dp))
// Name a few so an accidental Select-All is obvious before it's too late.
victims.take(5).forEach {
@@ -500,7 +503,7 @@ fun FileManagerScreen(
}
if (victims.size > 5) {
Text(
"…and ${victims.size - 5} more",
pluralStringResource(R.plurals.fm_and_more, victims.size - 5, victims.size - 5),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
@@ -516,19 +519,19 @@ fun FileManagerScreen(
isOperationRunning = true
var failed = 0
victims.forEachIndexed { i, f ->
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
}
isOperationRunning = false
operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (failed > 0) {
Toast.makeText(context, "$failed couldn't be deleted", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
}
}
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
}) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text("Cancel") } },
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text(stringResource(R.string.common_cancel)) } },
)
}
@@ -537,12 +540,12 @@ fun FileManagerScreen(
val isDir = conflict.isDirectory
OutlinedAlertDialog(
onDismissRequest = { pendingConflict = null },
title = { Text("\"${conflict.name}\" already exists") },
title = { Text(stringResource(R.string.fm_already_exists, conflict.name)) },
text = {
Column {
Text(
if (isDir) "Merge adds and replaces files inside the existing folder."
else "Overwrite replaces the existing file.",
if (isDir) stringResource(R.string.fm_conflict_merge_hint)
else stringResource(R.string.fm_conflict_overwrite_hint),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
@@ -553,15 +556,15 @@ fun FileManagerScreen(
checked = conflictApplyToAll,
onCheckedChange = { conflictApplyToAll = it },
)
Text("Apply to all conflicts", fontSize = 12.sp)
Text(stringResource(R.string.fm_conflict_apply_all), fontSize = 12.sp)
}
}
Spacer(Modifier.height(4.dp))
listOf(
(if (isDir) ConflictChoice.MERGE else ConflictChoice.OVERWRITE) to
(if (isDir) "Merge" else "Overwrite"),
ConflictChoice.KEEP_BOTH to "Keep both",
ConflictChoice.SKIP to "Skip",
(if (isDir) stringResource(R.string.fm_conflict_merge) else stringResource(R.string.fm_conflict_overwrite)),
ConflictChoice.KEEP_BOTH to stringResource(R.string.fm_conflict_keep_both),
ConflictChoice.SKIP to stringResource(R.string.fm_conflict_skip),
).forEach { (choice, label) ->
TextButton(
modifier = Modifier.fillMaxWidth(),
@@ -573,7 +576,7 @@ fun FileManagerScreen(
confirmButton = {},
dismissButton = {
TextButton(onClick = { conflictChoice = ConflictChoice.SKIP; pendingConflict = null }) {
Text("Cancel")
Text(stringResource(R.string.common_cancel))
}
},
)
@@ -603,7 +606,7 @@ fun FileManagerScreen(
) {
Icon(Icons.Filled.Folder, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Select this folder")
Text(stringResource(R.string.fm_select_this_folder))
}
}
// ── Path bar ──
@@ -619,10 +622,10 @@ fun FileManagerScreen(
// Don't climb above the current drive's root.
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
}, enabled = currentDir != currentRoot) {
Icon(Icons.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.primary)
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
}
val currentDriveLabel = describeLocation(currentDir).driveLabel
val currentDriveLabel = stringResource(describeLocation(currentDir).driveLabel)
// Dim the drive chip while the Favorites list is open (it's not the active context).
val driveChipAlpha = if (showFavorites) 0.45f else 1f
Box {
@@ -666,7 +669,7 @@ fun FileManagerScreen(
} else {
Toast.makeText(
context,
"${drive.label} is mounted but not readable right now",
context.getString(R.string.fm_drive_not_readable, drive.label),
Toast.LENGTH_SHORT,
).show()
}
@@ -680,7 +683,7 @@ fun FileManagerScreen(
if (showFavorites) {
Text(
text = "Favorites",
text = stringResource(R.string.fm_favorites),
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
@@ -718,7 +721,7 @@ fun FileManagerScreen(
) {
Icon(Icons.Filled.CreateNewFolder, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(5.dp))
Text("New Folder", color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
Text(stringResource(R.string.fm_new_folder), color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
}
}
IconButton(onClick = {
@@ -727,19 +730,19 @@ fun FileManagerScreen(
}) {
Icon(
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
if (gridView) "List view" else "Grid view",
if (gridView) stringResource(R.string.fm_list_view) else stringResource(R.string.fm_grid_view),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showSearch = !showSearch; if (!showSearch) searchQuery = "" }) {
Icon(Icons.Filled.Search, "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.Search, stringResource(R.string.fm_search), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box {
IconButton(onClick = { showSortMenu = true }) {
Icon(Icons.Filled.Sort, "Sort", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.Sort, stringResource(R.string.fm_sort), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
listOf("name" to "Name", "date" to "Date modified", "size" to "Size")
listOf("name" to stringResource(R.string.fm_sort_name), "date" to stringResource(R.string.fm_sort_date), "size" to stringResource(R.string.fm_prop_size))
.forEach { (key, label) ->
DropdownMenuItem(
text = {
@@ -758,7 +761,7 @@ fun FileManagerScreen(
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
DropdownMenuItem(
text = { Text(if (compactRows) "Comfortable rows" else "Compact rows") },
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
onClick = {
compactRows = !compactRows
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
@@ -766,7 +769,7 @@ fun FileManagerScreen(
},
)
DropdownMenuItem(
text = { Text(if (showHidden) "Hide hidden files" else "Show hidden files") },
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
onClick = {
showHidden = !showHidden
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
@@ -781,9 +784,9 @@ fun FileManagerScreen(
// Star toggle: open/close the dedicated Favorites list.
IconButton(onClick = { showFavorites = !showFavorites }) {
if (showFavorites) {
Icon(Icons.Filled.Star, "Hide favorites", tint = MaterialTheme.colorScheme.primary)
Icon(Icons.Filled.Star, stringResource(R.string.fm_hide_favorites), tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Filled.StarBorder, "Show favorites", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -794,7 +797,7 @@ fun FileManagerScreen(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
placeholder = { Text("Filter this folder", fontSize = 13.sp) },
placeholder = { Text(stringResource(R.string.fm_filter_folder), fontSize = 13.sp) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
)
}
@@ -821,7 +824,7 @@ fun FileManagerScreen(
)
if (freeSpace > 0) {
Text(
"${FileOps.formatBytes(freeSpace)} free",
stringResource(R.string.fm_free_space, FileOps.formatBytes(context, freeSpace)),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1,
@@ -842,7 +845,7 @@ fun FileManagerScreen(
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
"${selectedPaths.size} selected",
stringResource(R.string.fm_selected_count, selectedPaths.size),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
modifier = Modifier.weight(1f),
@@ -855,7 +858,7 @@ fun FileManagerScreen(
else entries.map { it.absolutePath }.toSet()
},
contentPadding = selBarPadding,
) { Text(if (selectedPaths.size == entries.size) "None" else "All", fontSize = 12.sp) }
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
@@ -866,7 +869,7 @@ fun FileManagerScreen(
selectedPaths = emptySet()
},
contentPadding = selBarPadding,
) { Text("Copy", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
@@ -877,19 +880,19 @@ fun FileManagerScreen(
selectedPaths = emptySet()
},
contentPadding = selBarPadding,
) { Text("Cut", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text("Delete", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = { selectionMode = false; selectedPaths = emptySet() },
contentPadding = selBarPadding,
) { Text("Done", fontSize = 12.sp) }
) { Text(stringResource(R.string.fm_done), fontSize = 12.sp) }
}
}
@@ -903,16 +906,16 @@ fun FileManagerScreen(
.clickable { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, "Paste", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
val what = if (clipboardFiles.size == 1) clipboardFiles.first().name
else "${clipboardFiles.size} items"
else pluralStringResource(R.plurals.fm_items, clipboardFiles.size, clipboardFiles.size)
Text(
"Paste $what${if (isCutOperation) " (move)" else ""} here",
if (isCutOperation) stringResource(R.string.fm_paste_move_here, what) else stringResource(R.string.fm_paste_here, what),
color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f),
)
TextButton(onClick = { clipboardFiles = emptyList(); isCutOperation = false }) {
Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
Text(stringResource(R.string.common_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
}
}
}
@@ -944,7 +947,7 @@ fun FileManagerScreen(
isOperationRunning = false
operationDeterminate = false
loadDirectory(currentDir, resetScroll = false)
}) { Text("Cancel", fontSize = 12.sp) }
}) { Text(stringResource(R.string.common_cancel), fontSize = 12.sp) }
}
}
Spacer(Modifier.height(4.dp))
@@ -970,12 +973,12 @@ fun FileManagerScreen(
// shown in pick mode (the themed picker keeps its slim layout). Built each recompose (cheap)
// so it tracks the current drive/favourites without stale click lambdas.
val fmRailState = rememberRailState("filemanager")
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File) =
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath) {
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File, shortLabel: String? = null) =
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath, shortLabel = shortLabel) {
showFavorites = false; openDrive(dir)
}
val storageItems = buildList {
add(locItem("Internal", Icons.Filled.Smartphone, File("/storage/emulated/0")))
add(locItem(stringResource(R.string.fm_drive_internal), Icons.Filled.Smartphone, File("/storage/emulated/0")))
drives.filter { it.removable }.forEach { d ->
add(RailItem(d.label, Icons.Filled.SdStorage, !showFavorites && currentRoot.absolutePath == d.dir.absolutePath) {
showFavorites = false; if (d.readable) openDrive(d.dir)
@@ -983,25 +986,25 @@ fun FileManagerScreen(
}
}
val quickItems = buildList {
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem("Downloads", Icons.Filled.Download, it)) }
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem("ROMs", Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem("Session logs", Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem("Pictures", Icons.Filled.Image, it)) }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
val locationSections = buildList {
add(RailSection("STORAGE", storageItems))
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
if (quickItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_quick), quickItems))
if (favItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_favorites), favItems))
}
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
// The slim picker keeps its layout: no rail.
if (!pickMode) {
CollapsibleRail(state = fmRailState, title = "Files", sections = locationSections, outlinedItems = true)
CollapsibleRail(state = fmRailState, title = stringResource(R.string.fm_rail_title), sections = locationSections, outlinedItems = true)
}
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
// ── Favorites list OR file list ──
@@ -1012,7 +1015,7 @@ fun FileManagerScreen(
onPinCurrent = {
FavoritesStore.add(context, currentDir.absolutePath)
favTick++
Toast.makeText(context, "Added \"${currentDir.name}\" to Favorites", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
},
onJump = { dir ->
showFavorites = false
@@ -1021,7 +1024,7 @@ fun FileManagerScreen(
onUnpin = { dir ->
FavoritesStore.remove(context, dir.absolutePath)
favTick++
Toast.makeText(context, "Removed \"${dir.name}\" from Favorites", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxSize(),
)
@@ -1095,8 +1098,8 @@ fun FileManagerScreen(
showMenuFor = null
Toast.makeText(
context,
if (nowFav) "Added \"${file.name}\" to Favorites"
else "Removed \"${file.name}\" from Favorites",
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
else context.getString(R.string.fm_favorite_removed, file.name),
Toast.LENGTH_SHORT,
).show()
},
@@ -1112,7 +1115,7 @@ fun FileManagerScreen(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("Empty directory", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.fm_empty_directory), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
@@ -1173,8 +1176,8 @@ fun FileManagerScreen(
showMenuFor = null
Toast.makeText(
context,
if (nowFav) "Added \"${file.name}\" to Favorites"
else "Removed \"${file.name}\" from Favorites",
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
else context.getString(R.string.fm_favorite_removed, file.name),
Toast.LENGTH_SHORT,
).show()
},
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.files
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -19,9 +21,9 @@ object FileOps {
/** Byte-accurate copy progress (copied/total bytes), reported as the copy proceeds. */
fun interface ProgressCallback { fun onProgress(copiedBytes: Long, totalBytes: Long) }
fun formatBytes(bytes: Long): String {
if (bytes <= 0) return "0 bytes"
val units = arrayOf("bytes", "KB", "MB", "GB", "TB")
fun formatBytes(context: Context, bytes: Long): String {
if (bytes <= 0) return context.getString(R.string.fm_size_zero)
val units = arrayOf(context.getString(R.string.fm_unit_bytes), "KB", "MB", "GB", "TB")
val group = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt().coerceIn(0, units.lastIndex)
return String.format(Locale.ENGLISH, "%.2f", bytes / Math.pow(1024.0, group.toDouble())) + " " + units[group]
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import java.io.File
@@ -26,6 +27,11 @@ import java.io.File
* - [EXTRA_PICKER_TITLE] the header
*/
class FilePickerActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -50,7 +56,7 @@ class FilePickerActivity : ComponentActivity() {
pickDirMode = pickDir,
pickExtensions = extensions,
initialDir = initialDir,
pickerTitle = title ?: if (pickDir) "Choose a folder" else "Choose a file",
pickerTitle = title ?: getString(if (pickDir) R.string.fm_choose_folder else R.string.fm_choose_file),
onPick = { file ->
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
finish()
@@ -25,6 +25,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -66,7 +68,7 @@ internal fun FilePropertiesDialog(
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
title = { Text(stringResource(R.string.fm_properties)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
@@ -79,14 +81,14 @@ internal fun FilePropertiesDialog(
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(stringResource(R.string.fm_prop_location), file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
stringResource(R.string.fm_prop_type),
if (file.isDirectory) stringResource(R.string.fm_type_folder)
else file.extension.uppercase().let { if (it.isBlank()) stringResource(R.string.fm_type_file) else stringResource(R.string.fm_type_ext_file, it) },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
if (!file.isDirectory) PropertyLine(stringResource(R.string.fm_prop_size), FileOps.formatBytes(context, file.length()))
PropertyLine(stringResource(R.string.fm_prop_modified), dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
@@ -95,8 +97,8 @@ internal fun FilePropertiesDialog(
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
label = stringResource(R.string.fm_read_only),
description = stringResource(R.string.fm_read_only_hint),
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
@@ -108,7 +110,7 @@ internal fun FilePropertiesDialog(
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.fm_read_only_failed, Toast.LENGTH_SHORT).show()
}
}
},
@@ -117,10 +119,10 @@ internal fun FilePropertiesDialog(
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
label = stringResource(R.string.fm_hidden),
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
stringResource(R.string.fm_hidden_unsupported)
else stringResource(R.string.fm_hidden_hint),
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
@@ -134,14 +136,14 @@ internal fun FilePropertiesDialog(
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.fm_hidden_failed, Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.fm_done)) } },
)
}
@@ -5,6 +5,7 @@ import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import com.droiddeck.launcher.R
import java.io.File
/**
@@ -63,7 +64,7 @@ object StorageRoots {
// Internal storage is never removable and never absent; seed it first so it heads the menu.
volumeOf(PRIMARY_KEY).apply {
label = "Internal"
label = context.getString(R.string.fm_drive_internal)
removable = false
candidates += File(INTERNAL_PATH)
}
@@ -109,13 +110,13 @@ object StorageRoots {
entry.seeds += dir
}
val roots = volumes.values.map { it.toRoot() }
val roots = volumes.values.map { it.toRoot(context) }
val internal = roots.filter { !it.removable }
val removable = roots.filter { it.removable }.sortedBy { it.label }
return disambiguate(internal + removable)
}
private fun Volume.toRoot(): StorageRoot {
private fun Volume.toRoot(context: Context): StorageRoot {
// Prefer a path we can actually list; a readable child beats an unreadable root.
val dir = candidates.firstOrNull(::canBrowse)
?: candidates.firstNotNullOfOrNull { root -> seeds.firstNotNullOfOrNull { deepestReadable(root, it) } }
@@ -125,14 +126,14 @@ object StorageRoots {
?: File("/storage/$key")
return StorageRoot(
label = label ?: defaultLabel(),
label = label ?: defaultLabel(context),
dir = dir,
removable = removable,
readable = canBrowse(dir),
)
}
private fun Volume.defaultLabel(): String = if (removable) "SD card" else key
private fun Volume.defaultLabel(context: Context): String = if (removable) context.getString(R.string.fm_drive_sd) else key
/** Appends the volume id to any label used by more than one volume, so entries stay tellable apart. */
private fun disambiguate(roots: List<StorageRoot>): List<StorageRoot> {
@@ -183,12 +184,12 @@ object StorageRoots {
* name like "android", which tells the user nothing about which card they are looking at.
*/
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
if (volume.isPrimary) return "Internal"
if (volume.isRemovable) return "SD card"
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
val description = volume.getDescription(context)?.trim()
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?: uuid
?: "Storage"
?: context.getString(R.string.fm_drive_storage)
}
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
@@ -37,18 +38,18 @@ object GameFileSync {
}
@Synchronized private fun sync(context: Context, folder: File) {
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
// A disconnected library must not be interpreted as a mass uninstall.
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
val storage = GameStorage.effective(context)
val storageSetting = SessionPrefs.gameStorage(context)
val previousStorage = prefs(context).getString("storagePath", null)
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
}
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
}
@@ -6,6 +6,7 @@ import android.util.AtomicFile
import androidx.core.util.readText
import androidx.core.util.writeBytes
import androidx.core.util.writeText
import com.droiddeck.launcher.R
import org.json.JSONObject
import java.io.File
import java.io.InputStream
@@ -59,18 +60,18 @@ object GameFiles {
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
require(folder.isDirectory) { "Folder is unavailable" }
val target = child(folder, filename(game))
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File {
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
val target = child(context, folder, filename(game))
val text = contents(game.gameIdString)
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
require(!target.exists() || matches(target, text)) { context.getString(R.string.gfiles_name_taken, target.name) }
if (!target.exists()) write(target, text)
exportArt(folder, game)
exportArt(context, folder, game)
return target
}
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
private fun exportArt(folder: File, game: Library.SteamGame) {
private fun exportArt(context: Context, folder: File, game: Library.SteamGame) {
val art = listOfNotNull(game.icon, game.art).firstOrNull {
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
} ?: return
@@ -79,27 +80,27 @@ object GameFiles {
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
if (title.isEmpty()) return
val images = child(folder, "images")
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
val target = child(images, "$title-icon.${art.extension.lowercase()}")
val images = child(context, folder, "images")
check(images.isDirectory || images.mkdir()) { context.getString(R.string.gfiles_art_unavailable) }
val target = child(context, images, "$title-icon.${art.extension.lowercase()}")
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
}
/** Only remove files in our inventory whose content is still exactly what we wrote. */
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
require(folder.isDirectory) { "Folder is unavailable" }
val inventory = child(folder, INVENTORY)
@Synchronized fun sync(context: Context, folder: File, games: List<Library.SteamGame>) {
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
val inventory = child(context, folder, INVENTORY)
val previous = if (inventory.exists()) {
val record = JSONObject(AtomicFile(inventory).readText())
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
require(record.optString("format") == FORMAT) { context.getString(R.string.gfiles_unrelated_inventory) }
record.getJSONObject("games")
} else JSONObject()
val next = JSONObject()
val exports = games.distinctBy { it.gameId }.map { game ->
val target = child(folder, filename(game))
val target = child(context, folder, filename(game))
// Keep an edited export without letting it block the rest of the library sync.
val file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
!matches(target, contents(game.gameIdString))) target else export(folder, game)
!matches(target, contents(game.gameIdString))) target else export(context, folder, game)
next.put(file.name, game.gameIdString)
file
}
@@ -107,22 +108,22 @@ object GameFiles {
for (name in previous.keys()) {
val id = previous.getString(name)
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
val file = child(folder, name)
val file = child(context, folder, name)
if (matches(file, contents(id))) {
// Android shared storage is often case insensitive. A case-only rename may
// still resolve to the live export, which must not be pruned as an old file.
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
if (!retained) check(file.delete()) { "Could not remove $name" }
if (!retained) check(file.delete()) { context.getString(R.string.gfiles_remove_failed, name) }
}
}
}
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
}
private fun child(folder: File, name: String): File {
private fun child(context: Context, folder: File, name: String): File {
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
val file = File(folder, name)
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
require(file.canonicalFile.parentFile == folder.canonicalFile) { context.getString(R.string.gfiles_outside_folder) }
return file
}
@@ -33,6 +33,8 @@ object Library {
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The shown name of [system] when it is words rather than a platform name, else 0. */
val systemRes: Int get() = if (id == "retroarch") R.string.content_system_many else 0
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
val iconRes: Int get() = when (id) {
"rpcs3" -> R.drawable.emu_rpcs3; "armsx2" -> R.drawable.emu_pcsx2; "dolphin" -> R.drawable.emu_dolphin
@@ -109,7 +111,7 @@ object Library {
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
if (strictRead && steamapps.isDirectory) check(manifests != null) { context.getString(R.string.game_sync_err_manifests) }
manifests?.sortedBy { it.name }?.forEach { manifest ->
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
@@ -84,7 +85,7 @@ object DriverBundle {
unzip(context, uri, work)
val manifest = File(work, MANIFEST).takeIf { it.isFile }
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
?: throw IllegalArgumentException(context.getString(R.string.bundle_no_manifest))
val androidDir = File(work, manifest.androidPath)
val linuxDir = File(work, manifest.linuxPath)
@@ -135,7 +136,7 @@ object DriverBundle {
for (entry in generateSequence { zip.nextEntry }) {
if (entry.isDirectory) continue
val out = File(root, entry.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException(context.getString(R.string.bundle_entry_outside, entry.name))
out.parentFile?.mkdirs()
out.outputStream().use { sink ->
val buf = ByteArray(1 shl 16)
@@ -143,7 +144,7 @@ object DriverBundle {
val r = zip.read(buf)
if (r <= 0) break
total += r
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
if (total > MAX_BYTES) throw IllegalArgumentException(context.getString(R.string.bundle_too_large, MAX_BYTES shr 20))
sink.write(buf, 0, r)
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.os.Build
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
@@ -29,7 +31,10 @@ data class GpuInfo(
A6XX("Adreno 6xx"),
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
ADRENO_UNKNOWN("Adreno"),
NOT_ADRENO("Not an Adreno GPU"),
NOT_ADRENO("Not an Adreno GPU");
/** [label] in the app's language: the Adreno families are names, the rest is words. */
fun label(context: Context): String = if (this == NOT_ADRENO) context.getString(R.string.gpuinfo_not_adreno) else label
}
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
@@ -42,7 +47,7 @@ data class GpuInfo(
else -> Support.UNTESTED
}
/** One line for the device card and the system check. */
/** One line for the device card and the system check, in English for the device report. */
val supportText: String
get() = when (support) {
Support.TESTED -> "Supported"
@@ -51,7 +56,20 @@ data class GpuInfo(
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
}
/** [supportText] in the app's language. */
fun supportText(context: Context): String = context.getString(when (support) {
Support.TESTED -> R.string.gpuinfo_supported
Support.UNTESTED -> if (family == Family.A7XX_LOW) R.string.gpuinfo_experimental else R.string.gpuinfo_below_tested
Support.UNSUPPORTED -> R.string.gpuinfo_unsupported
})
/** [name] for the screen: the stand-in for a GPU the device does not name is in the app's language. */
fun displayName(context: Context): String = if (name == UNNAMED) context.getString(R.string.gpu_this_gpu) else name
companion object {
/** [name] when the device names no GPU; English, as the device report shows it. */
private const val UNNAMED = "this GPU"
fun detect(): GpuInfo {
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
@@ -61,7 +79,7 @@ data class GpuInfo(
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
name = if (!adreno) Build.HARDWARE.ifBlank { UNNAMED } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
)
@@ -4,6 +4,7 @@ import android.content.Context;
import android.net.Uri;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.FileUtils;
import org.json.JSONObject;
@@ -181,20 +182,17 @@ public class LinuxVulkanDriverManager {
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
try {
if (soName == null) {
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
throw new IllegalArgumentException(context.getString(R.string.lvd_no_library));
}
File so = new File(tmpDir, LIB_NAME);
if (!isAarch64Elf(so)) {
throw new IllegalArgumentException(soName + " is not a 64-bit AArch64 ELF shared library.");
throw new IllegalArgumentException(context.getString(R.string.lvd_not_aarch64, soName));
}
// The one check that actually separates this from the bionic builds. Both sonames
// are in the driver's .dynstr, so a byte scan is enough and needs no ELF parsing:
// glibc's is versioned ("libc.so.6"), bionic's is not ("libc.so").
if (!containsAscii(so, "libc.so.6")) {
throw new IllegalArgumentException(soName + " is not a glibc driver - it links Android's libc. "
+ "The Linux runtime needs a \"-Linux\" zip; a plain or \"-Wayland\" Turnip cannot be "
+ "loaded by the Steam client at all.");
throw new IllegalArgumentException(context.getString(R.string.lvd_not_glibc, soName));
}
String kind = zipMeta != null ? zipMeta.optString("kind", "") : "";
if (!kind.isEmpty() && !"linux-vulkan-icd".equals(kind)) {
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.gpu
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
import kotlin.math.abs
@@ -82,11 +84,15 @@ data class ScreenEffects(
* SessionPrefs.upscalerChoices mode the Look switches to.
*/
data class Look(
/** The Look's id: what the row picks by and the compositor logs. */
val name: String,
val effects: ScreenEffects,
val scalingMode: Int? = null,
/** One line under the row while this Look is picked. */
val desc: String,
/** [name] and [desc] in the app's language, for the menu. */
@StringRes val label: Int,
@StringRes val descRes: Int,
)
object ScreenEffectLooks {
@@ -96,29 +102,29 @@ object ScreenEffectLooks {
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
val LOOKS: List<Look> = listOf(
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
Look("Off", ScreenEffects.OFF, desc = "Nothing applied.", label = R.string.frame_gen_off, descRes = R.string.look_off_desc),
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
desc = "Sharpening with a touch of contrast - the everyday one."),
desc = "Sharpening with a touch of contrast - the everyday one.", label = R.string.look_game_clarity, descRes = R.string.look_game_clarity_desc),
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
desc = "Colour pushed hard. Deband on so skies don't stripe."),
desc = "Colour pushed hard. Deband on so skies don't stripe.", label = R.string.look_vivid, descRes = R.string.look_vivid_desc),
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
desc = "Deeper contrast, cooler colour, edges smoothed."),
desc = "Deeper contrast, cooler colour, edges smoothed.", label = R.string.look_cinematic, descRes = R.string.look_cinematic_desc),
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
desc = "Shadows lifted and heavy sharpening - spot people first."),
desc = "Shadows lifted and heavy sharpening - spot people first.", label = R.string.look_competitive, descRes = R.string.look_competitive_desc),
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
desc = "Sharpening only, colour untouched."),
desc = "Sharpening only, colour untouched.", label = R.string.look_adaptive_sharpen, descRes = R.string.look_adaptive_sharpen_desc),
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
desc = "Film-like curve, slightly muted."),
desc = "Film-like curve, slightly muted.", label = R.string.look_filmic, descRes = R.string.look_filmic_desc),
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
desc = "Loud and punchy."),
desc = "Loud and punchy.", label = R.string.look_arcade, descRes = R.string.look_arcade_desc),
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
desc = "The CRT tube and analogue signal passes together."),
desc = "The CRT tube and analogue signal passes together.", label = R.string.look_retro_crt, descRes = R.string.look_retro_crt_desc),
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
desc = "For running below panel resolution."),
desc = "For running below panel resolution.", label = R.string.look_upscale_sharp, descRes = R.string.look_upscale_sharp_desc),
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
desc = "Crisp pixels for 2D and old titles."),
desc = "Crisp pixels for 2D and old titles.", label = R.string.look_pixel_clean, descRes = R.string.look_pixel_clean_desc),
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
desc = "Toon outlines plus sharpening."),
desc = "Toon outlines plus sharpening.", label = R.string.look_anime_edge, descRes = R.string.look_anime_edge_desc),
)
/**
@@ -5,6 +5,7 @@ import android.net.Uri;
import android.os.Environment;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.TarZst;
import com.droiddeck.launcher.session.SessionPrefs;
@@ -218,7 +219,7 @@ public final class TurnipDriver {
*/
String adopt(File tmpDir, String displayName) throws IOException {
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
if (rejected != null) throw new IllegalArgumentException(context.getString(R.string.turnip_not_adrenotools, rejected));
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
@@ -245,31 +246,31 @@ public final class TurnipDriver {
* Why a just-extracted zip is not something adrenotools can be pointed at, or null when it is.
* The kinds refused by name are the other driver lists' zips, which carry a meta.json too.
*/
private static String rejectionReason(File dir) {
private String rejectionReason(File dir) {
File metaFile = new File(dir, "meta.json");
if (!metaFile.isFile()) return "no meta.json";
if (!metaFile.isFile()) return context.getString(R.string.turnip_reject_no_meta);
String libraryName, kind;
try {
JSONObject meta = new JSONObject(FileUtils.readString(metaFile));
libraryName = meta.optString("libraryName", "");
kind = meta.optString("kind", "");
} catch (Exception e) {
return "meta.json is unreadable (" + e.getMessage() + ")";
return context.getString(R.string.turnip_reject_meta_unreadable, e.getMessage());
}
if ("linux-vulkan-icd".equals(kind))
return "this is a Linux runtime driver (import it under \"Linux runtime driver\")";
return context.getString(R.string.turnip_reject_linux_driver);
if ("wayland-game-driver".equals(kind))
return "this is a Wayland game driver, which this app has no use for";
return context.getString(R.string.turnip_reject_wayland);
if (libraryName.isEmpty())
return "meta.json names no libraryName, so nothing could be handed to AdrenoTools";
return context.getString(R.string.turnip_reject_no_library_name);
File library = new File(dir, libraryName);
if (!library.isFile())
return "meta.json names " + libraryName + ", which is not in the zip";
return context.getString(R.string.turnip_reject_library_missing, libraryName);
if (!LinuxVulkanDriverManager.isAarch64Elf(library))
return libraryName + " is not a 64-bit AArch64 ELF shared library";
return context.getString(R.string.turnip_reject_not_aarch64, libraryName);
// A glibc build that happens to carry a libraryName would load nothing in this process.
if (LinuxVulkanDriverManager.containsAscii(library, "libc.so.6"))
return libraryName + " links glibc - a \"-Linux\" Turnip, which belongs under \"Linux runtime driver\"";
return context.getString(R.string.turnip_reject_glibc, libraryName);
return null;
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONArray
@@ -95,7 +96,7 @@ object TurnipReleases {
var lastError: Exception? = null
for (src in SOURCES) {
try {
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=15"))
val seen = HashSet<String>()
var newest: String? = null
for (i in 0 until releases.length()) {
@@ -135,7 +136,7 @@ object TurnipReleases {
return parse(stored)
}
private fun get(url: String): String {
private fun get(context: Context, url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
@@ -143,8 +144,8 @@ object TurnipReleases {
c.setRequestProperty("User-Agent", "DroidDeck-app")
try {
val code = c.responseCode
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
if (code != 200) throw IOException("GitHub answered HTTP $code")
if (code == 403 || code == 429) throw IOException(context.getString(R.string.turnip_rate_limited))
if (code != 200) throw IOException(context.getString(R.string.turnip_github_http, code))
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
@@ -194,7 +195,7 @@ object TurnipReleases {
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
@@ -202,7 +203,7 @@ object TurnipReleases {
c.setRequestProperty("User-Agent", "DroidDeck-app")
c.instanceFollowRedirects = true
try {
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
c.inputStream.use { input ->
FileOutputStream(target).use { out ->
@@ -220,7 +221,7 @@ object TurnipReleases {
}
}
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("Checksum mismatch - the download was discarded")
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
}
return target
} catch (e: Exception) {
@@ -24,11 +24,18 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var controls: OnScreenControls
override fun onCreate(savedInstanceState: Bundle?) {
@@ -48,10 +55,10 @@ class ControllerEditorActivity : ComponentActivity() {
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
) {
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton("Reset", false) { controls.resetLayout() }
EditorButton("Cancel", false) { finish() }
EditorButton("Save", true) {
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
}
@@ -1,20 +1,21 @@
package com.droiddeck.launcher.input
import android.content.Context
import com.droiddeck.launcher.R
object ControllerPrefs {
const val STEAM_BLUE = 0xFF1A9FFF.toInt()
const val OFF = "off"
val tints = listOf(
STEAM_BLUE to "Steam blue",
0xFF66C0F4.toInt() to "Sky",
0xFFE8EEF4.toInt() to "White",
0xFFC77DFF.toInt() to "Violet",
0xFF3DDC84.toInt() to "Green",
0xFFFFA726.toInt() to "Amber",
0xFFFF5252.toInt() to "Red",
0xFFFF6FB5.toInt() to "Pink",
STEAM_BLUE to R.string.ctrl_tint_steam_blue,
0xFF66C0F4.toInt() to R.string.ctrl_tint_sky,
0xFFE8EEF4.toInt() to R.string.ctrl_tint_white,
0xFFC77DFF.toInt() to R.string.ctrl_tint_violet,
0xFF3DDC84.toInt() to R.string.ctrl_tint_green,
0xFFFFA726.toInt() to R.string.ctrl_tint_amber,
0xFFFF5252.toInt() to R.string.ctrl_tint_red,
0xFFFF6FB5.toInt() to R.string.ctrl_tint_pink,
)
val opacities = listOf(40, 60, 80, 100)
@@ -22,17 +23,17 @@ object ControllerPrefs {
val sizes = listOf(80, 90, 100, 110, 125)
val mappable = listOf(
"a" to "A button", "b" to "B button", "x" to "X button", "y" to "Y button",
"lb" to "Left bumper", "rb" to "Right bumper", "lt" to "Left trigger", "rt" to "Right trigger",
"select" to "View button", "start" to "Menu button",
"a" to R.string.ctrl_button_a, "b" to R.string.ctrl_button_b, "x" to R.string.ctrl_button_x, "y" to R.string.ctrl_button_y,
"lb" to R.string.ctrl_button_lb, "rb" to R.string.ctrl_button_rb, "lt" to R.string.ctrl_button_lt, "rt" to R.string.ctrl_button_rt,
"select" to R.string.ctrl_button_view, "start" to R.string.ctrl_button_menu,
)
val targets = listOf(
"a" to "A", "b" to "B", "x" to "X", "y" to "Y",
"lb" to "LB", "rb" to "RB", "lt" to "LT", "rt" to "RT",
"l3" to "L3", "r3" to "R3", "select" to "View", "start" to "Menu", "guide" to "Steam",
"up" to "D-pad up", "down" to "D-pad down", "left" to "D-pad left", "right" to "D-pad right",
OFF to "Hidden",
"a" to R.string.ctrl_target_a, "b" to R.string.ctrl_target_b, "x" to R.string.ctrl_target_x, "y" to R.string.ctrl_target_y,
"lb" to R.string.ctrl_target_lb, "rb" to R.string.ctrl_target_rb, "lt" to R.string.ctrl_target_lt, "rt" to R.string.ctrl_target_rt,
"l3" to R.string.ctrl_target_l3, "r3" to R.string.ctrl_target_r3, "select" to R.string.ctrl_target_view, "start" to R.string.ctrl_target_menu, "guide" to R.string.ctrl_target_steam,
"up" to R.string.ctrl_target_up, "down" to R.string.ctrl_target_down, "left" to R.string.ctrl_target_left, "right" to R.string.ctrl_target_right,
OFF to R.string.ctrl_target_hidden,
)
class Settings(
@@ -2,14 +2,16 @@ package com.droiddeck.launcher.input
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
/** The control surface shown on a secondary Android display during a Linux session. */
enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
import android.os.StatFs
import android.system.Os
import android.util.Log
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
@@ -27,6 +29,11 @@ object AppImageManager {
val guestDir: String get() = "$GUEST_DIR/$id"
}
/** Why a file cannot be imported: [text] with its [args]. */
internal class Problem(@StringRes val text: Int, vararg val args: Any) {
fun describe(context: Context): String = context.getString(text, *args)
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
@@ -44,22 +51,22 @@ object AppImageManager {
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
internal fun problem(file: File): Problem? {
if (!file.isFile) return Problem(R.string.user_apps_file_gone)
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return Problem(R.string.appimg_too_small) }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
return Problem(R.string.appimg_unreadable, e.message.toString())
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
return Problem(R.string.appimg_not_linux)
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> Problem(R.string.appimg_wrong_arch)
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> Problem(R.string.appimg_not_appimage)
head[10].toInt() != 2 -> Problem(R.string.appimg_wrong_type, head[10].toInt())
else -> null
}
}
@@ -83,12 +90,12 @@ object AppImageManager {
fun import(
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), onProgress: (String) -> Unit,
): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
problem(file)?.let { return it.describe(context) }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_import, FileUtils.sizeToString(file.length() * 3))
}
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
try {
@@ -103,17 +110,17 @@ object AppImageManager {
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
return e.message ?: context.getString(R.string.appimg_import_failed)
}
}
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
problem(file)?.let { return it }
problem(file)?.let { return it.describe(context) }
val dir = dir(context, id)
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
if (!File(dir, "app").isDirectory) return context.getString(R.string.appimg_gone)
if (StatFs(dir.path).availableBytes < file.length() * 3) {
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_update, FileUtils.sizeToString(file.length() * 3))
}
return try {
extract(context, dir, file, onProgress) ?: run {
@@ -123,7 +130,7 @@ object AppImageManager {
}
} catch (e: Exception) {
Log.e(TAG, "replace $id", e)
e.message ?: "Update failed"
e.message ?: context.getString(R.string.appimg_update_failed)
}
}
@@ -135,10 +142,10 @@ object AppImageManager {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
onProgress(context.getString(R.string.user_apps_copying, file.name))
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
onProgress(context.getString(R.string.appimg_extracting, file.name))
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
val out = StringBuilder()
@@ -152,7 +159,8 @@ object AppImageManager {
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
return out.lines().lastOrNull { it.isNotBlank() }?.let { context.getString(R.string.appimg_extract_failed_detail, it) }
?: context.getString(R.string.appimg_extract_failed)
}
copyLinkedFiles(File(dir, "app"), links)
return null
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.core.ArchivePaths
@@ -24,13 +25,17 @@ object DeckyManager {
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
/** True while a plugin's remote binary downloads: what the UI branches on, not the stage's words. */
@Volatile var downloadingBinary = false
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
if (!loader.isFile || !loader.canExecute()) return null
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: context.getString(R.string.setup_installed)
}
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
@@ -102,39 +107,40 @@ object DeckyManager {
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing Decky Loader"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_loader)
val target = loader(context)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return "Decky Loader download failed" }
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return context.getString(R.string.deckymgr_checksum_mismatch) }
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
val staged = File(target.parentFile, "PluginLoader.new")
staged.delete()
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
if (!temp.renameTo(staged)) { temp.delete(); return context.getString(R.string.deckymgr_stage_failed) }
val old = File(target.parentFile, "PluginLoader.old")
old.delete()
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
if (target.exists() && !target.renameTo(old)) { staged.delete(); return context.getString(R.string.deckymgr_loader_preserve_failed) }
if (!staged.renameTo(target)) {
if (old.exists()) old.renameTo(target)
staged.delete()
return "Could not activate the new PluginLoader"
return context.getString(R.string.deckymgr_loader_activate_failed)
}
old.delete()
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
@@ -145,43 +151,44 @@ object DeckyManager {
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing a plugin"
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_plugin)
if (installed(context) == null) return context.getString(R.string.deckymgr_loader_required)
if (!archive.isFile || !archive.canRead()) return context.getString(R.string.deckymgr_zip_unreadable)
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
if (!plugins.exists() && !plugins.mkdirs()) return context.getString(R.string.deckymgr_plugins_dir_failed)
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
try {
staging.mkdirs()
progress("Extracting plugin ZIP", -1)
extractPluginZip(archive, staging)
progress(context.getString(R.string.deckymgr_extracting_zip), -1)
extractPluginZip(context, archive, staging)
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
if (roots.size != 1 || staging.listFiles()?.size != 1) return context.getString(R.string.deckymgr_zip_one_folder)
val plugin = roots.single()
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
return context.getString(R.string.deckymgr_zip_missing_files)
}
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
require(name.isNotBlank()) { "Plugin name is missing" }
require(name.isNotBlank()) { context.getString(R.string.deckymgr_plugin_name_missing) }
val metadata = JSONObject(File(plugin, "package.json").readText())
downloadRemoteBinaries(metadata, plugin, progress)
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
downloadRemoteBinaries(context, metadata, plugin, progress)
if (SessionState.running) return context.getString(R.string.deckymgr_session_started_plugin)
DeckyPluginInstall.activate(plugin, plugins, name,
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
progress("Plugin installed · restart Steam to load it", 100)
progress(context.getString(R.string.deckymgr_plugin_installed), 100)
return null
} catch (e: Exception) {
Log.w(TAG, "plugin ZIP import", e)
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
if (e is DeckyPluginInstall.Failure) return context.getString(e.text)
return e.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.deckymgr_plugin_install_failed)
} finally {
staging.deleteRecursively()
}
}
private fun extractPluginZip(archive: File, destination: File) {
private fun extractPluginZip(context: Context, archive: File, destination: File) {
var totalBytes = 0L
var entries = 0
val seen = HashSet<String>()
@@ -189,30 +196,30 @@ object DeckyManager {
while (true) {
val entry = zip.nextEntry ?: break
entries++
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
require(entries <= 10_000) { context.getString(R.string.deckymgr_zip_too_many) }
val relative = entry.name.replace('\\', '/')
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
require(relative.isNotBlank() && !relative.startsWith('/')) { context.getString(R.string.deckymgr_zip_invalid_path) }
require(seen.add(relative)) { context.getString(R.string.deckymgr_zip_duplicate_paths) }
val output = ArchivePaths.inside(destination, relative)
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
?: throw IllegalArgumentException(context.getString(R.string.deckymgr_zip_unsafe_path))
if (entry.isDirectory) {
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
require(output.mkdirs() || output.isDirectory) { context.getString(R.string.deckymgr_folder_failed) }
} else {
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { context.getString(R.string.deckymgr_folder_failed) }
FileOutputStream(output).use { out ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = zip.read(buffer)
if (read < 0) break
totalBytes += read
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { context.getString(R.string.deckymgr_zip_too_large) }
out.write(buffer, 0, read)
}
}
// Decky distributions place plugin executables under bin/. Java's ZIP reader
// does not expose Unix mode bits, so restore execution on those payload files.
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
throw IllegalStateException("Could not mark bundled plugin binary executable")
throw IllegalStateException(context.getString(R.string.deckymgr_bundled_not_executable))
}
}
zip.closeEntry()
@@ -220,42 +227,49 @@ object DeckyManager {
}
}
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
private fun downloadRemoteBinaries(context: Context, metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
val binaries = metadata.optJSONArray("remote_binary") ?: return
val bin = File(plugin, "bin")
for (i in 0 until binaries.length()) {
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException(context.getString(R.string.deckymgr_remote_entry_invalid))
val name = binary.optString("name")
val url = binary.optString("url")
val hash = binary.optString("sha256hash")
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { context.getString(R.string.deckymgr_remote_name_invalid) }
require(url.startsWith("https://")) { context.getString(R.string.deckymgr_remote_url_https) }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { context.getString(R.string.deckymgr_remote_hash_invalid, name) }
require(bin.mkdirs() || bin.isDirectory) { context.getString(R.string.deckymgr_bin_dir_failed) }
val temp = File(bin, ".$name.download")
progress("Downloading plugin binary · $name", -1)
if (!Downloader.downloadFile(url, temp, false) { fraction ->
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}) {
val downloading = context.getString(R.string.deckymgr_downloading_binary, name)
downloadingBinary = true
progress(downloading, -1)
val downloaded = try {
Downloader.downloadFile(url, temp, false) { fraction ->
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}
} finally {
downloadingBinary = false
}
if (!downloaded) {
temp.delete()
throw IllegalStateException("Could not download plugin binary $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_download_failed, name))
}
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
temp.delete()
throw IllegalStateException("Plugin binary checksum mismatch · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_checksum_mismatch, name))
}
if (!temp.setExecutable(true, false)) {
temp.delete()
throw IllegalStateException("Could not mark plugin binary executable · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_not_executable, name))
}
val target = File(bin, name)
if (target.exists() && !target.delete()) {
temp.delete()
throw IllegalStateException("Could not replace plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_replace_failed, name))
}
if (!temp.renameTo(target)) {
temp.delete()
throw IllegalStateException("Could not install plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_install_failed, name))
}
}
}
@@ -1,11 +1,16 @@
package com.droiddeck.launcher.runtime
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
internal object DeckyPluginInstall {
/** A failure the user is told about: [text] is its string resource. */
class Failure(@StringRes val text: Int) : IllegalStateException()
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
val existing = plugins.listFiles().orEmpty().filter { folder ->
@@ -14,7 +19,7 @@ internal object DeckyPluginInstall {
}.getOrDefault(false)
}
val target = File(plugins, staged.name)
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
if (target.exists() && target !in existing) throw Failure(R.string.deckymgr_folder_taken)
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
@@ -34,15 +39,15 @@ internal object DeckyPluginInstall {
pending.writeText(settings.toString(4))
for (old in existing) {
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
if (!old.renameTo(backup)) throw Failure(R.string.deckymgr_preserve_failed)
backups.add(old to backup)
}
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
if (!staged.renameTo(target)) throw Failure(R.string.deckymgr_activate_failed)
activated = true
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
if (!pending.renameTo(settingsFile)) throw Failure(R.string.deckymgr_settings_failed)
} catch (error: Exception) {
if (activated) target.deleteRecursively()
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
for ((old, backup) in backups.asReversed()) if (!backup.renameTo(old)) throw Failure(R.string.deckymgr_restore_failed)
throw error
} finally {
pending.delete()
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
@@ -81,20 +82,21 @@ object DesktopCatalog {
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
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) { f ->
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
listener?.onProgress("Verifying", -1)
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 "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
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)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -102,20 +104,20 @@ object DesktopCatalog {
// Some projects zip the AppImage (melonDS); the one file inside is what we want.
val placed = if (entry.url.endsWith(".zip", ignoreCase = true)) unzipAppImage(download, target)
else download.renameTo(target)
if (!placed) return "Could not place the AppImage"
if (!placed) return context.getString(R.string.deskpkg_place_failed)
target.setExecutable(true, false)
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> if (!LinuxRuntimeInstaller.extract(download, root, listener)) return "Extraction failed"
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
FileUtils.writeString(marker(context, entry.id), entry.version)
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: "Install failed"
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
@@ -102,7 +103,7 @@ object FlatpakManager {
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
@@ -114,11 +115,11 @@ object FlatpakManager {
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
status != 0 -> context.getString(R.string.flatpakmgr_setup_failed, status)
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
else -> null
}
} ?: "Another store task is running"
} ?: context.getString(R.string.flatpakmgr_busy)
}
/**
@@ -126,51 +127,61 @@ object FlatpakManager {
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
return exclusive("$verb:${id ?: "all"}") {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
var step = context.getString(R.string.flatpakmgr_starting)
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"op" -> { step = stage(context, o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
} ?: context.getString(R.string.flatpakmgr_busy)
}
private fun stage(o: JSONObject): String {
private fun stage(context: Context, o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
if (ref.isEmpty()) return o.optString("status").ifEmpty { context.getString(R.string.setup_check_busy) }
val name = refLabel(ref) { id, args -> context.getString(id, *args) }
val n = o.optInt("n")
val kind = o.optString("kind")
return if (n > 1) context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing_step
"update" -> R.string.flatpakmgr_updating_step
else -> R.string.flatpakmgr_installing_step
}, name, o.optInt("i"), n)
else context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing
"update" -> R.string.flatpakmgr_updating
else -> R.string.flatpakmgr_installing
}, name)
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
/**
* A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". [text]
* resolves a string resource with its arguments.
*/
internal fun refLabel(ref: String, text: (Int, Array<out Any>) -> String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
val branch = parts.getOrNull(3)
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Locale") -> text(R.string.flatpakmgr_ref_translations, emptyArray())
".GL." in id || ".GL32." in id -> text(R.string.flatpakmgr_ref_graphics_drivers, emptyArray())
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> text(R.string.flatpakmgr_ref_media_codecs, emptyArray())
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
}.let { if (branch != null) text(R.string.flatpakmgr_ref_runtime_branch, arrayOf(it, branch)) else text(R.string.flatpakmgr_ref_runtime, arrayOf(it)) }
else -> id.substringAfterLast('.')
}
}
@@ -0,0 +1,74 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.AppLanguage
import java.io.File
import java.util.Locale
/**
* Fonts for every script the app's languages (and Steam's) are written in.
*
* The runtime ships DejaVu and Adwaita only, which have no Chinese, Japanese, Korean or Thai, so
* Steam drew those as boxes (#118, #206). Android already carries Noto for all of them in
* /system/fonts; the guest gets that folder read-only at [GUEST_DIR], where fontconfig finds it
* with the rest of /usr/share/fonts. Nothing is downloaded and the runtime is not changed.
*
* One CJK font holds Chinese, Japanese and Korean under one set of characters, and a character
* they share is drawn the way the chosen region writes it. [preferenceConf] puts that region's
* variant first, after DejaVu, which stays the font for Latin text.
*/
object GuestFonts {
const val HOST_DIR = "/system/fonts"
const val GUEST_DIR = "/usr/share/fonts/android"
/** Rewritten at every session start, so it follows the language picked in Setup. */
const val CONF = "etc/fonts/conf.d/65-droiddeck-language.conf"
/** The bind spec for Android's fonts, or null on a device without the folder. */
@JvmStatic
fun bindSpec(): String? = File(HOST_DIR).takeIf { it.isDirectory }?.let { "${it.path}:$GUEST_DIR" }
/** Writes the guest's font preference for the app's current language; best effort. */
@JvmStatic
fun prepare(context: Context, root: File) {
val conf = File(root, CONF)
val text = preferenceConf(AppLanguage.effective(context))
try {
if (conf.isFile && conf.readText() == text) return
conf.parentFile?.mkdirs()
conf.writeText(text)
} catch (e: Exception) {
// fontconfig still finds every font; shared characters just take its own default shape.
}
}
/** The CJK variant whose shapes match how [locale]'s readers write shared characters. */
fun cjkRegion(locale: Locale): String {
val script = locale.script
return when (locale.language) {
"ja" -> "JP"
"ko" -> "KR"
"zh" -> when {
script == "Hans" -> "SC"
locale.country == "HK" || locale.country == "MO" -> "HK"
script == "Hant" || locale.country == "TW" -> "TC"
else -> "SC"
}
else -> "SC"
}
}
fun preferenceConf(locale: Locale): String {
val region = cjkRegion(locale)
fun alias(generic: String, latin: String, cjk: String) =
" <alias binding=\"same\">\n <family>$generic</family>\n <prefer>\n" +
" <family>$latin</family>\n <family>$cjk $region</family>\n" +
" </prefer>\n </alias>\n"
return "<?xml version=\"1.0\"?>\n<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">\n" +
"<!-- Written by DroidDeck at session start for its language (${locale.toLanguageTag()}). -->\n" +
"<fontconfig>\n" +
alias("sans-serif", "DejaVu Sans", "Noto Sans CJK") +
alias("serif", "DejaVu Serif", "Noto Serif CJK") +
alias("monospace", "DejaVu Sans Mono", "Noto Sans Mono CJK") +
"</fontconfig>\n"
}
}
@@ -270,6 +270,13 @@ public final class LinuxRuntime {
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
// Android's fonts, for the scripts the runtime's own fonts lack (GuestFonts). Last, so a
// fast path that keeps only its first binds drops these before a game library.
String fonts = GuestFonts.bindSpec();
if (fonts != null) {
bind(cmd, fonts);
GuestFonts.prepare(context, root);
}
List<String> specs = new ArrayList<>();
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
return specs;
@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
@@ -44,9 +45,17 @@ public final class LinuxRuntimeInstaller {
private static final String VERSION_FILE = ".version";
/** What a stage is doing, for code that branches on it rather than on its (translated) words. */
public enum Step { DOWNLOADING, VERIFYING, OTHER }
public interface ProgressListener {
/** {@code percent} is -1 while the size is unknown. */
void onProgress(String stage, int percent);
/** The same with its {@link Step}; a listener that branches on the step overrides this one. */
default void onProgress(Step step, String stage, int percent) {
onProgress(stage, percent);
}
}
public static final class Release {
@@ -71,11 +80,12 @@ public final class LinuxRuntimeInstaller {
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile String stage;
volatile Step step = Step.OTHER;
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
Job(boolean removal) { this.removal = removal; }
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
}
private static final Object JOB_LOCK = new Object();
@@ -131,16 +141,23 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) {
if (running != null && running.removal) return false;
owner = running == null;
if (owner) { running = new Job(false); removalError = null; }
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
job.ok = installOnce(context.getApplicationContext(), release, new ProgressListener() {
@Override public void onProgress(String stage, int percent) {
onProgress(Step.OTHER, stage, percent);
}
@Override public void onProgress(Step step, String stage, int percent) {
job.step = step;
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(step, stage, percent);
}
});
return job.ok;
} finally {
@@ -192,7 +209,7 @@ public final class LinuxRuntimeInstaller {
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
if (listener != null) listener.onProgress(job.step, job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
@@ -209,13 +226,14 @@ public final class LinuxRuntimeInstaller {
// A failed/interrupted explicit removal must finish before a fresh installation.
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (listener != null) listener.onProgress("Downloading", 0);
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
if (listener != null) {
listener.onProgress("Downloading",
listener.onProgress(Step.DOWNLOADING, downloading,
fraction < 0 ? -1 : Math.round(fraction * 100f));
}
});
@@ -224,7 +242,7 @@ public final class LinuxRuntimeInstaller {
return false;
}
if (listener != null) listener.onProgress("Verifying", -1);
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
@@ -239,8 +257,8 @@ public final class LinuxRuntimeInstaller {
recoverInterruptedSwap(root, staging, old);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
if (!extract(archive, staging, listener)) {
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -307,30 +325,35 @@ public final class LinuxRuntimeInstaller {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
return beginUninstall(LinuxRuntime.rootDir(context));
return beginUninstall(context.getApplicationContext(), LinuxRuntime.rootDir(context));
}
static Removal beginUninstall(File root) {
/** {@code app} words the progress; unit tests pass null and get none. */
static Removal beginUninstall(Context app, File root) {
synchronized (JOB_LOCK) {
if (running != null) return null;
removalError = null;
running = new Job(true);
running.stage = "Removing Linux runtime";
return new Removal(root, running);
running = new Job(true, text(app, R.string.rtinst_removing));
return new Removal(app, root, running);
}
}
private static String text(Context app, int id) {
return app == null ? "" : app.getString(id);
}
public static final class Removal {
private final Context app;
private final File root;
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Removal(File root, Job job) { this.root = root; this.job = job; }
private Removal(Context app, File root, Job job) { this.app = app; this.root = root; this.job = job; }
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
report("Removing Linux runtime", -1);
report(text(app, R.string.rtinst_removing), -1);
final long[] removed = {0};
final long[] lastShown = {0};
java.util.function.LongConsumer progress = ignored -> {
@@ -338,7 +361,8 @@ public final class LinuxRuntimeInstaller {
long now = System.nanoTime();
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
lastShown[0] = now;
report("Removing Linux runtime · " + removed[0] + " entries", -1);
report(app == null ? "" : app.getResources().getQuantityString(
R.plurals.rtinst_removing_entries, (int) Math.min(removed[0], Integer.MAX_VALUE), removed[0]), -1);
}
};
File pending = removalDirectory(root);
@@ -355,10 +379,10 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
job.ok = true;
report("Linux runtime removed", 100);
report(text(app, R.string.rtinst_removed), 100);
return true;
} catch (IOException e) {
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
removalError = text(app, R.string.rtinst_removal_failed);
Log.w(TAG, "runtime removal incomplete", e);
report(removalError, -1);
return false;
@@ -380,8 +404,9 @@ public final class LinuxRuntimeInstaller {
* empty file instead of its link target is a rootfs that boots to nothing. Symlinks, hard
* links and the executable bit are all carried over here.
*/
public static boolean extract(File archive, File destination, ProgressListener listener) {
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -432,7 +457,7 @@ public final class LinuxRuntimeInstaller {
continue; // device nodes and fifos: the runtime binds the real ones
}
if (++entries % 2000 == 0 && listener != null) {
listener.onProgress("Extracting", -1);
listener.onProgress(extracting, -1);
}
}
return true;
@@ -22,12 +22,15 @@ import com.droiddeck.launcher.R
* two gets there first.
*/
class RuntimeInstallService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
@@ -53,9 +56,9 @@ class RuntimeInstallService : Service() {
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime is installed or removed"
description = getString(R.string.rtinst_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -65,7 +68,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
.setContentTitle(getString(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)
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -247,12 +248,13 @@ object ComponentsManager {
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
/** The FEX build's version name; [unnamed] when its DLL carries none, "" when there is no FEX. */
private fun fexVersion(dir: File, unnamed: String = "FEX (build without a version name)"): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
?: unnamed
}
private fun compFiles(dir: File, comp: String): List<String> {
@@ -273,23 +275,24 @@ object ComponentsManager {
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
private fun readProfile(context: Context, f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
throw IllegalArgumentException(context.getString(R.string.cmgr_no_profile, f.name))
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
private fun packageInfo(context: Context, f: File): Package {
val prof = readProfile(context, f)
val comp = TYPES[prof.optString("type")]
?: throw IllegalArgumentException(context.getString(R.string.cmgr_not_component_package, f.name, prof.optString("type")))
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
@@ -362,7 +365,7 @@ object ComponentsManager {
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(context, f).optString("description") }.getOrDefault(""), f.length())
}
}
}
@@ -414,24 +417,24 @@ object ComponentsManager {
val syncPacks = EsyncPacks.status(root(context))
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val detected = if (comp == "fex") fexVersion(p.dir, context.getString(R.string.cmgr_fex_unnamed)) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") context.getString(if (File(p.dir, FEX_FILES[2]).isFile) R.string.cmgr_fex_linux_helpers else R.string.cmgr_fex_dlls_only) else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
active == null -> context.getString(R.string.comp_tag_original)
active.optString("protonVersion") != p.version -> context.getString(R.string.cmgr_original_proton_updated)
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) originalLabel(context, active) ?: active.optString("label", active.optString("file"))
else context.getString(R.string.cmgr_changed_outside)
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
q?.let { originalLabel(context, it) ?: it.optString("label") },
)
}
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
@@ -439,6 +442,16 @@ object ComponentsManager {
Snapshot(views, packages(context))
}
/**
* The label of a chosen or queued original, in the app's language: the stored "label" was written
* in English when it was chosen. Null for a package, whose label is its version.
*/
private fun originalLabel(context: Context, entry: JSONObject): String? = when {
entry.optString("kind") == "original" -> context.getString(R.string.cmgr_original_version, entry.optString("protonVersion"))
entry.optString("file").startsWith("original ") -> context.getString(R.string.cmgr_original_from, entry.optString("file").removePrefix("original "))
else -> null
}
// ------------------------------------------------------------------ launch-time enforcement
/**
@@ -507,17 +520,17 @@ object ComponentsManager {
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val info = packageInfo(context, wcp)
val state = loadState(context)
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
return context.getString(R.string.cmgr_swap_queued, LABEL[info.comp], info.version, p.name)
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
context.getString(R.string.cmgr_swap_done, LABEL[info.comp], info.version, p.name)
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
@@ -533,17 +546,17 @@ object ComponentsManager {
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
check(wcp.isFile) { context.getString(R.string.cmgr_original_missing) }
val state = loadState(context)
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
return context.getString(R.string.cmgr_restore_queued, LABEL[comp], p.name)
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
@@ -575,7 +588,7 @@ object ComponentsManager {
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
applyPackage(context, p, packageInfo(context, wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
@@ -599,27 +612,27 @@ object ComponentsManager {
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
"Deleted $name."
context.getString(R.string.cmgr_deleted, name)
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
check(safeName(p.version) != protonVersion) { context.getString(R.string.cmgr_original_kept) }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
check(f.isFile) { context.getString(R.string.cmgr_not_stored) }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
context.getString(R.string.cmgr_deleted_original, protonVersion, LABEL[comp])
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val info = packageInfo(context, source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
@@ -662,14 +675,14 @@ object ComponentsManager {
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
@@ -49,7 +49,6 @@ object EsyncPacks {
private const val TOOLS = "tools.tsv"
private const val FLOOR = "floor"
private const val PACK_FAILURES = "packFailures"
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
private const val INDEX_LIMIT = 4L shl 20
private const val SIG_LIMIT = 4096L
private const val ASSET_LIMIT = 256L shl 20
@@ -618,14 +617,15 @@ object EsyncPacks {
return false
}
try {
onProgress?.invoke(PROGRESS_LABEL, -1)
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
onProgress?.invoke(label, -1)
var reported = -1
val fetched = FileOutputStream(part).use { output ->
fetch(entry.asset.url, entry.asset.size, output) { done ->
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
if (percent != reported) {
reported = percent
onProgress?.invoke(PROGRESS_LABEL, percent)
onProgress?.invoke(label, percent)
}
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
@@ -25,9 +27,12 @@ import java.util.zip.ZipOutputStream
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
WINLATOR("Winlator zip", "drive_c/", "xuser"),
enum class Layout(val app: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub", "/drive_c/", "steamuser"),
WINLATOR("Winlator", "drive_c/", "xuser");
/** "GameHub zip", in the app's language. */
fun label(context: Context): String = context.getString(R.string.saves_layout_zip, app)
}
/** A game, its prefix, and the save folders found in it. */
@@ -127,12 +132,12 @@ object GameSaves {
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
*/
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
fun export(context: Context, game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
dir.mkdirs()
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
if (count == 0) { out.delete(); error(context.getString(R.string.saves_none_found, game.game.name)) }
return out to count
}
@@ -162,11 +167,11 @@ object GameSaves {
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
*/
fun import(game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
fun import(context: Context, game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error(context.getString(R.string.saves_not_launched, game.game.name))
val driveC = File(prefix, "drive_c")
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
if (!driveC.isDirectory) error(context.getString(R.string.saves_not_launched, game.game.name))
if (!looksLikeSaveZip(zip)) error(context.getString(R.string.saves_not_save_zip, zip.name))
backupsDir().mkdirs()
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Environment
import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
@@ -17,7 +18,8 @@ import java.io.File
* slowly (intro movies, big asset loads); internal stays the default and the dialog says so.
*/
object GameStorage {
class Option(val label: String, val path: String)
/** A library root: [label] as the menu lists it (a volume with its free space), [name] alone. */
class Option(val label: String, val path: String, val name: String = label)
/** Every removable volume, as its app folder, with the free space it has. */
fun options(context: Context): List<Option> {
@@ -26,8 +28,8 @@ object GameStorage {
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
if (!removable) return@mapNotNull null
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: "SD card"
Option("$name · ${free(dir)} free", File(dir, "steam").absolutePath)
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
}
}
@@ -41,15 +43,15 @@ object GameStorage {
* Makes [path] a library root the session can bind - the folder and its steamapps/ - and proves
* it writable. Returns why it cannot be used, or null when it can.
*/
fun prepare(path: String): String? {
fun prepare(context: Context, path: String): String? {
val root = File(path)
val steamapps = File(root, "steamapps")
if (!steamapps.isDirectory && !steamapps.mkdirs()) return "cannot create folders in $path"
if (!steamapps.isDirectory && !steamapps.mkdirs()) return context.getString(R.string.gstore_cannot_create, path)
val probe = File(steamapps, ".writable")
return try {
if (!probe.createNewFile() && !probe.isFile) "cannot write in $path" else { probe.delete(); null }
if (!probe.createNewFile() && !probe.isFile) context.getString(R.string.gstore_cannot_write, path) else { probe.delete(); null }
} catch (e: Exception) {
"cannot write in $path (${e.message})"
context.getString(R.string.gstore_cannot_write_detail, path, e.message)
}
}
@@ -58,13 +60,13 @@ object GameStorage {
val pref = SessionPrefs.gameStorage(context)
return when {
pref == SessionPrefs.GAME_STORAGE_OFF -> null
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.label.substringBefore(" ·"), it.path) }
pref.isEmpty() -> options(context).firstOrNull()?.let { Option(it.name, it.path) }
else -> Option(SessionPrefs.gameStorageLabel(context), pref)
}
}
/** The label the client shows for a chosen folder: its last name, or the volume's. */
fun labelFor(context: Context, path: String): String =
options(context).firstOrNull { it.path == path }?.label?.substringBefore(" ·")
?: File(path).name.ifEmpty { "Folder" }
options(context).firstOrNull { it.path == path }?.name
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
}
@@ -17,9 +17,16 @@ import java.nio.charset.StandardCharsets
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context, steam: Boolean = true) {
class LoadingState(context: Context, private val steam: Boolean = true) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var step by mutableStateOf(context.getString(com.droiddeck.launcher.R.string.loadstate_starting))
private set
/** What [step] is about, so the screen picks its checklist stage without reading the (translated) words. */
var topic by mutableStateOf(Topic.SESSION)
private set
/** [step] is a download or install line worth showing as it is, not one of the script's own notes. */
var readable by mutableStateOf(false)
private set
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
@@ -31,15 +38,23 @@ class LoadingState(context: Context, steam: Boolean = true) {
private val hints = context.resources.getStringArray(
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
)
private val res = context.resources
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
fun tick() {
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
elapsed = String.format(java.util.Locale.US, "%d:%02d elapsed · still working", seconds / 60, seconds % 60)
elapsed = res.getString(com.droiddeck.launcher.R.string.loadstate_elapsed, seconds / 60, seconds % 60)
hint = hints[((seconds / 8) % hints.size).toInt()]
}
/** A line the app itself writes (an install's progress, the session starting). */
fun say(line: String, topic: Topic, readable: Boolean) {
step = line
this.topic = topic
this.readable = readable
}
fun showEnded(message: String, detail: String? = null) {
visible = true
ended = true
@@ -50,12 +65,16 @@ class LoadingState(context: Context, steam: Boolean = true) {
/** Re-reads the end of the log and updates the line and the bar. */
fun update(context: Context, log: File?) {
val state = read(context, log) ?: return
step = state.first
percent = state.second
step = state.line
topic = state.topic
readable = state.readable
percent = state.percent
}
private class Line(val line: String, val percent: Int, val topic: Topic, val readable: Boolean)
/** Only the tail is read: the client alone writes megabytes an hour. */
private fun read(context: Context, log: File?): Pair<String, Int>? {
private fun read(context: Context, log: File?): Line? {
if (log == null || !log.isFile) return null
val text = try {
RandomAccessFile(log, "r").use { file ->
@@ -100,16 +119,45 @@ class LoadingState(context: Context, steam: Boolean = true) {
}
}
return when {
downloadAt > stepAt && downloadPercent >= 0 ->
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
clientDownloadAt == stepAt && clientDownload != null ->
Pair("Downloading the Steam client · $clientDownload", clientPercent)
stepText != null -> Pair(stepText, -1)
downloadAt > stepAt && downloadPercent >= 0 -> Line(
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent,
Topic.STEAM, readable = true,
)
clientDownloadAt == stepAt && clientDownload != null -> Line(
context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent,
Topic.STEAM, readable = true,
)
stepText != null -> Line(stepText, -1, topicOf(stepText, steam), readableScriptStep(stepText))
else -> null
}
}
/** The checklist stages a loading line can move the screen to. */
enum class Topic { RUNTIME, SESSION, DESKTOP, STEAM, STEAM_STARTING, OTHER }
companion object {
/**
* A session script "== STEP" line's topic in a Steam ([steam]) or desktop session; those
* lines are English whatever the app's language.
*/
internal fun topicOf(scriptStep: String, steam: Boolean): Topic {
val t = scriptStep.lowercase()
return when {
"linux runtime" in t -> Topic.RUNTIME
"starting the session" in t -> Topic.SESSION
!steam && "desktop" in t -> Topic.DESKTOP
steam && "starting the steam client" in t -> Topic.STEAM_STARTING
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> Topic.STEAM
else -> Topic.OTHER
}
}
/** The script's install lines are worth reading as they are; its other notes are not. */
internal fun readableScriptStep(scriptStep: String): Boolean {
val t = scriptStep.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
}
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
@@ -30,7 +30,7 @@ object OfflineMode {
private fun loginUsers(root: File) = File(root, "root/.local/share/Steam/config/loginusers.vdf")
/** Whether the client has an account it could sign in as offline. */
/** The account the client could sign in as offline: its name, "" when it recorded none, null when there is none. */
fun account(context: Context): String? {
val file = loginUsers(LinuxRuntime.rootDir(context))
val text = try {
@@ -39,7 +39,7 @@ object OfflineMode {
Log.w(TAG, "could not read loginusers.vdf", e); return null
}
if (!REMEMBER.containsMatchIn(text)) return null
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() } ?: "signed in"
return PERSONA.find(text)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }.orEmpty()
}
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY, false)
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.R
import android.content.Context
import android.os.StatFs
import android.util.Log
@@ -59,70 +60,72 @@ object ProtonExtras {
/** Downloads, verifies and registers the latest build without waiting for another session. */
@Synchronized
fun install(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
if (installInProgress) return "A compatibility tool install is already running"
if (installInProgress) return context.getString(R.string.pextra_busy)
installInProgress = true
return try { installNow(context, tool, onProgress) } finally { installInProgress = false }
}
private fun installNow(context: Context, tool: Tool, onProgress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing compatibility tools"
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
if (SessionState.running) return context.getString(R.string.pextra_stop_session)
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
val asset = findLatestAsset(tool) ?: return "Could not find an ARM64 release for ${tool.name}"
val asset = findLatestAsset(tool) ?: return context.getString(R.string.pextra_no_release, tool.name)
val downloads = File(context.filesDir, "proton-downloads").apply { mkdirs() }
val archive = File(downloads, "${tool.id}-${asset.name}")
if (asset.size > 0 && archive.length() > asset.size) archive.delete()
val missing = (asset.size - archive.length()).coerceAtLeast(0L)
if (StatFs(LinuxRuntime.rootDir(context).path).availableBytes < NEED_BYTES + missing) {
return "At least 4 GB free plus the download size is required to install ${tool.name}"
return context.getString(R.string.pextra_no_space, tool.name)
}
onProgress("Downloading ${tool.name} ${asset.tag}", 0)
val downloading = context.getString(R.string.pextra_downloading, tool.name, asset.tag)
onProgress(downloading, 0)
var downloaded = false
for (attempt in 0 until 5) {
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
onProgress("Downloading ${tool.name} ${asset.tag}", if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
}
if (downloaded) break
if (attempt < 4) {
onProgress("Retrying download · attempt ${attempt + 2} of 5", -1)
onProgress(context.getString(R.string.pextra_retrying, attempt + 2, 5), -1)
try { Thread.sleep(3_000) } catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return "Download interrupted"
return context.getString(R.string.pextra_interrupted)
}
}
}
if (!downloaded) return "Download failed; the partial file is kept so a retry can resume"
if (!downloaded) return context.getString(R.string.pextra_download_failed)
if (asset.size > 0 && archive.length() != asset.size) {
if (archive.length() > asset.size) {
archive.delete()
return "Download exceeded the release size and was deleted"
return context.getString(R.string.pextra_too_big)
}
return "Download was incomplete; the partial file is kept for a resumable retry"
return context.getString(R.string.pextra_incomplete)
}
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
// release's own sha512 file. A download neither vouches for is not installed.
onProgress("Verifying download", -1)
onProgress(context.getString(R.string.pextra_verifying), -1)
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return "Could not read the release checksum; try again"
?: return context.getString(R.string.pextra_checksum_unreadable)
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
else -> {
archive.delete()
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
return context.getString(R.string.pextra_no_checksum, tool.name, asset.tag)
}
}
if (!verified) {
archive.delete()
return "Checksum mismatch; the download was deleted"
return context.getString(R.string.pextra_checksum_mismatch)
}
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
onProgress("Installing ${tool.name}", -1)
if (SessionState.running) return context.getString(R.string.pextra_session_started)
val installing = context.getString(R.string.pextra_installing, tool.name)
onProgress(installing, -1)
return try {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
@@ -146,17 +149,17 @@ object ProtonExtras {
lines.forEach { line ->
Log.i(TAG, line)
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
onProgress("Installing ${tool.name}", -1)
onProgress(installing, -1)
}
}
}
val status = child.waitFor()
if (status != 0) "${tool.name} installation failed (exit $status)"
if (status != 0) context.getString(R.string.pextra_install_failed, tool.name, status)
else {
archive.delete()
unqueue(context, tool)
if (EsyncPacks.enabled(context)) {
onProgress("Fetching droiddeck-esync pack", -1)
onProgress(context.getString(R.string.esync_fetching), -1)
try {
EsyncPacks.fetchWanted(context, root, onProgress)
} catch (t: Throwable) {
@@ -167,7 +170,7 @@ object ProtonExtras {
}
} catch (e: Exception) {
Log.e(TAG, "install ${tool.id}", e)
e.message ?: "Could not install ${tool.name}"
e.message ?: context.getString(R.string.pextra_could_not_install, tool.name)
}
}
@@ -41,6 +41,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
@@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
@@ -75,6 +76,6 @@ object SessionLogShare {
.putExtra(Intent.EXTRA_SUBJECT, zip.nameWithoutExtension)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
send.clipData = ClipData.newRawUri(zip.name, uri)
return Intent.createChooser(send, "Share session logs").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.session
import android.content.Context
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
@@ -18,10 +20,12 @@ object SessionPrefs {
const val OSC_STEAM_QAM = "steam-qam"
const val OSC_NEVER = "never"
const val BACK_MENU_THEN_QAM = "1: menu 2: QAM"
const val BACK_QAM_THEN_MENU = "1: QAM 2: menu"
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
fun backActionsOrder(inverted: Boolean): String =
@StringRes
fun backActionsOrder(inverted: Boolean): Int =
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
@@ -669,6 +673,10 @@ object SessionPrefs {
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/** [fpsLimitChoices] labelled in the app's language. */
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
@@ -680,6 +688,16 @@ object SessionPrefs {
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
/** The [upscalerChoices] labels that are words rather than product names. */
private val upscalerLabels = mapOf(
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
)
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
@@ -714,6 +732,10 @@ object SessionPrefs {
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
/** [textureAnisotropyChoices] labelled in the app's language. */
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
it to when (it) {
TextureFiltering.LOD_BIAS_OFF -> "Off"
@@ -722,6 +744,15 @@ object SessionPrefs {
}
}
/** [textureLodBiasChoices] labelled in the app's language. */
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
value to when (value) {
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
else -> label
}
}
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
fun textureAnisotropy(context: Context): Int =
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
@@ -59,6 +59,9 @@ import java.util.Locale
* The activity comes and goes on top of this; see [com.droiddeck.launcher.wayland.CompositorHost].
*/
class SessionService : Service() {
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
@@ -537,6 +540,12 @@ class SessionService : Service() {
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Steam's interface language: the app's (Setup's choice, or the system's). The system's
// region decides between Spain's and Latin American Spanish, which the app has one of.
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
com.droiddeck.launcher.core.AppLanguage.effective(this),
com.droiddeck.launcher.core.AppLanguage.system(this).country,
))
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
@@ -793,7 +802,7 @@ class SessionService : Service() {
val library = GameStorage.effective(this)
var storageDiagnosticLibrary: File? = null
if (library != null) {
val problem = GameStorage.prepare(library.path)
val problem = GameStorage.prepare(this, library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
// Links, prefixes and Steam entries made before the rename still name the old path.
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.session
import java.util.Locale
/**
* Steam's name for a language: the API language codes the client and the Steamworks API use
* ("english", "schinese", "koreana"...), for every language Steam's interface comes in.
*/
object SteamLanguage {
const val DEFAULT = "english"
/** Every language the Steam client's interface is offered in. */
val all: Set<String> = setOf(
"arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english",
"finnish", "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese",
"koreana", "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian",
"spanish", "latam", "swedish", "thai", "turkish", "ukrainian", "vietnamese", "malay",
)
/** Spanish-speaking regions Steam serves with Latin American Spanish rather than Spain's. */
private val latinAmerica = setOf(
"419", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "MX", "NI", "PA", "PE",
"PR", "PY", "SV", "US", "UY", "VE",
)
/**
* Steam's language for [locale]. [region] stands in for the locale's own when it has none: the
* app's Spanish carries no region, and a player in Mexico should get Latin American Spanish.
*/
fun forLocale(locale: Locale, region: String? = null): String {
val country = locale.country.ifEmpty { region.orEmpty() }
return when (locale.language) {
"ar" -> "arabic"
"bg" -> "bulgarian"
"cs" -> "czech"
"da" -> "danish"
"nl" -> "dutch"
"en" -> "english"
"fi" -> "finnish"
"fr" -> "french"
"de" -> "german"
"el" -> "greek"
"hu" -> "hungarian"
"id", "in" -> "indonesian"
"it" -> "italian"
"ms" -> "malay"
"ja" -> "japanese"
"ko" -> "koreana"
"nb", "no", "nn" -> "norwegian"
"pl" -> "polish"
"pt" -> if (country == "BR") "brazilian" else "portuguese"
"ro" -> "romanian"
"ru" -> "russian"
"es" -> if (country in latinAmerica) "latam" else "spanish"
"sv" -> "swedish"
"th" -> "thai"
"tr" -> "turkish"
"uk" -> "ukrainian"
"vi" -> "vietnamese"
"zh" -> when {
locale.script == "Hant" -> "tchinese"
locale.script == "Hans" -> "schinese"
country == "TW" || country == "HK" || country == "MO" -> "tchinese"
else -> "schinese"
}
else -> DEFAULT
}
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.store
import android.util.Log
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
@@ -32,13 +34,13 @@ object FlathubApi {
)
/** A category as Flathub's search filters it, with the name shown for it. */
class Category(val id: String, val label: String)
class Category(val id: String, @StringRes val label: Int)
val categories = listOf(
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
Category("Utility", "Utilities"),
Category("Game", R.string.store_cat_games), Category("AudioVideo", R.string.store_cat_audio_video), Category("Graphics", R.string.store_cat_graphics),
Category("Network", R.string.store_cat_internet), Category("Office", R.string.store_cat_office), Category("Development", R.string.store_cat_developer),
Category("Education", R.string.store_cat_education), Category("Science", R.string.store_cat_science), Category("System", R.string.store_cat_system),
Category("Utility", R.string.store_cat_utilities),
)
/** Front-page collections: popular, trending, recently-updated, recently-added. */
@@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.FlatpakManager
/**
@@ -49,7 +50,7 @@ object StoreState {
private set
private var searchSeq = 0
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
fun refresh(context: Context) {
val app = context.applicationContext
@@ -97,7 +98,7 @@ object StoreState {
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
if (busy != null) return
val app = context.applicationContext
busy = what; stage = "Starting…"; percent = -1
busy = what; stage = app.getString(R.string.store_starting); percent = -1
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
@@ -109,12 +110,12 @@ object StoreState {
main.post {
busy = null; stage = null; percent = -1
ready = r; installed = list
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
if (problem != null) Toast.makeText(app, app.getString(R.string.store_problem, label, problem), Toast.LENGTH_LONG).show()
}
}, "store-$what").start()
}
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
fun setup(context: Context) = run(context, "setup", context.getString(R.string.store_flatpak_setup)) { c, p -> FlatpakManager.setup(c, p) }
fun install(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
@@ -135,7 +136,7 @@ object StoreState {
val u = FlatpakManager.updates(app)
main.post {
checkingUpdates = false
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, R.string.store_updates_check_failed, Toast.LENGTH_SHORT).show()
}
}, "store-updates").start()
}
@@ -49,12 +49,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
val tintOpen = host.open == "controller-tint"
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: stringResource(R.string.ctrl_custom), tintOpen) {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTint(color)
host.open = null
}
@@ -90,8 +90,9 @@ fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String)
onBack = onBack,
) {
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
for ((id, name) in ControllerPrefs.mappable) {
ChoiceRow(host, "map-$id", name, null, ControllerPrefs.targets, mapping[id] ?: id) { onPick(id, it) }
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
}
}
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
@@ -197,7 +197,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
if (row.tag.isNotEmpty()) Text(
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
driverTagLabel(row.tag), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
/**
* One line of text in a slot of fixed width - a rail label, a key cap - set as large as
* [maxSize] when it fits and smaller, down to [minSize], when it does not. Labels are as long as
* their language makes them ("Components" is "コンポーネント", "Componentes", "구성 요소"), so a
* fixed size cuts some of them off; below [minSize] the end is ellipsized rather than clipped.
*/
@Composable
fun FitText(
text: String,
maxSize: TextUnit,
minSize: TextUnit = 9.sp,
modifier: Modifier = Modifier,
fontWeight: FontWeight? = null,
color: Color = Color.Unspecified,
) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val measurer = rememberTextMeasurer()
val style = LocalTextStyle.current
val width = constraints.maxWidth
val size = remember(text, width, maxSize, minSize, fontWeight, style) {
if (width == Constraints.Infinity) return@remember maxSize
var s = maxSize.value
while (s > minSize.value) {
val measured = measurer.measure(
text, style.copy(fontSize = s.sp, fontWeight = fontWeight ?: style.fontWeight),
maxLines = 1, softWrap = false,
)
if (measured.size.width <= width) break
s -= 0.5f
}
s.coerceAtLeast(minSize.value).sp
}
Text(
text, fontSize = size, fontWeight = fontWeight, color = color, maxLines = 1, softWrap = false,
overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
)
}
}
@@ -307,7 +307,7 @@ private fun Content(
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
e.system.replaceFirstChar { it.uppercase() }.let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
@@ -334,7 +334,7 @@ private fun Content(
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
@@ -379,7 +379,7 @@ private fun Content(
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow(stringResource(R.string.content_desktop_system, e.system))
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
}
}
Rise(1) { Title(g.name) }
@@ -391,7 +391,7 @@ private fun Content(
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton(stringResource(R.string.content_launch_in, e.name), enabled = !s.busy, main = true) { a.onRom(g) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
@@ -403,7 +403,7 @@ private fun Content(
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
@@ -419,7 +419,6 @@ private fun Content(
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
@@ -428,25 +427,33 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
BusyChip(s)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
// The button goes under the title unless the card is wide enough for both side by side: in some
// languages its label alone takes half a small phone's pane.
BoxWithConstraints(Modifier.fillMaxWidth()) {
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (!narrow) actions()
if (narrow) actions()
}
if (narrow) actions()
}
}
/** How wide the desktop card must be to keep its button beside the title. */
private val DesktopCardInlineWidth = 640.dp
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
private sealed class GridItem(val key: String) {
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
@@ -454,25 +461,33 @@ private sealed class GridItem(val key: String) {
data object Add : GridItem("add")
}
/** Wide tiles - three across, or a list on a narrow page. */
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
@Composable
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
BoxWithConstraints(Modifier.fillMaxWidth()) {
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
// name no room at all.
val columns = when {
LocalNarrowPane.current -> 1
maxWidth < 720.dp -> 2
else -> 3
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
@@ -658,10 +673,14 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
* contrast - dimming it read as disabled rather than one press from installing.
*/
@Composable
private fun systemLabel(e: Library.Emulator): String =
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val system = e.system.replaceFirstChar { it.uppercase() }
val system = systemLabel(e)
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
@@ -174,7 +174,7 @@ private fun RailItem(
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (height < 56.dp) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
FitText(label, maxSize = 12.sp, modifier = Modifier.padding(horizontal = 4.dp), fontWeight = FontWeight.SemiBold, color = fg)
}
if (badge) Box(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
@@ -101,6 +101,8 @@ class FrontEndState(
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
/** The language picked in Setup, or AppLanguage.SYSTEM to follow the system. */
val language: String = "",
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
@@ -169,6 +171,7 @@ class FrontEndActions(
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLanguage: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
@@ -179,10 +182,10 @@ class FrontEndActions(
val onBackActionsInverted: (Boolean) -> Unit = {},
val onRefreshPhantomStatus: () -> Unit = {},
val onOpenDeveloperOptions: (Int?) -> Unit = {},
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit,
val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit,
val onCopyPhantomCommand: (Boolean) -> Unit = {},
val onDismissPhantomGate: () -> Unit = {},
val onStartWirelessAdbPairing: () -> Unit = {},
@@ -71,6 +71,7 @@ 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 com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -158,7 +159,7 @@ internal fun SetupPanel(
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val gpuName = remember { DeviceSupport.gpuName(ctx) }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
@@ -202,8 +203,8 @@ internal fun SetupPanel(
else -> stringResource(R.string.setup_gpu_unsupported)
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.displayName(ctx), gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
)
@@ -237,7 +238,7 @@ internal fun SetupPanel(
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
@@ -250,8 +251,8 @@ internal fun SetupPanel(
// raw command live on the full page Wireless debugging opens.
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
if (limitBlocks) PhantomProcessLimit.gateInstructions(ctx, s.phantomProcessStatus)
else PhantomProcessLimit.instructions(ctx, s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Actions {
@@ -270,7 +271,13 @@ internal fun SetupPanel(
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in),
s.offlineAccount?.let {
when {
it.isEmpty() -> stringResource(if (s.offline) R.string.setup_signed_in_offline_unnamed else R.string.setup_signed_in_unnamed)
s.offline -> stringResource(R.string.setup_signed_in_offline, it)
else -> stringResource(R.string.setup_signed_in, it)
}
} ?: stringResource(R.string.setup_sign_in),
divider = false,
)
}
@@ -287,10 +294,10 @@ internal fun SetupPanel(
2 -> {
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
@@ -304,13 +311,14 @@ internal fun SetupPanel(
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
LanguageRow(host, s.language, a.onLanguage)
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
@@ -322,10 +330,10 @@ internal fun SetupPanel(
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
@@ -470,3 +478,32 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/**
* The app's language: "System default" (what Android uses, named in brackets) or one of the
* languages the app ships, each in its own script. Steam is set to the same language when it starts.
*/
@Composable
private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit) {
val context = androidx.compose.ui.platform.LocalContext.current
val systemName = AppLanguage.displayName(AppLanguage.system(context))
val systemLabel = stringResource(R.string.setup_language_system, systemName)
SettingsRow(stringResource(R.string.setup_language), stringResource(R.string.setup_language_hint)) {
Box {
ValueChip(if (chosen == AppLanguage.SYSTEM) stringResource(R.string.setup_language_system_short) else AppLanguage.nativeName(chosen),
host.open == "language") { host.open = if (host.open == "language") null else "language" }
AnchoredMenu(host.open == "language", onDismiss = { if (host.open == "language") host.open = null }, title = stringResource(R.string.setup_language)) { firstItemFocus ->
MenuItem(systemLabel, checked = chosen == AppLanguage.SYSTEM, focusRequester = firstItemFocus) {
host.open = null
onPick(AppLanguage.SYSTEM)
}
AppLanguage.supported.forEach { tag ->
MenuItem(AppLanguage.nativeName(tag), checked = chosen == tag) {
host.open = null
onPick(tag)
}
}
}
}
}
}
@@ -167,7 +167,7 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -200,9 +200,11 @@ private fun DeviceCard(s: GpuDriversState) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
// needs: a long one (in some languages) would squeeze the name to a letter a line.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
@@ -38,6 +39,16 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
}
}
/** A [DriverRow.tag] as shown, in the app's language. */
@Composable
internal fun driverTagLabel(tag: String): String = when (tag) {
DriverRow.BUNDLED -> stringResource(R.string.driver_tag_bundled)
DriverRow.DOWNLOADED -> stringResource(R.string.driver_tag_downloaded)
DriverRow.IMPORTED -> stringResource(R.string.driver_tag_imported)
DriverRow.BUNDLE -> stringResource(R.string.driver_tag_bundle)
else -> tag
}
/** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */
class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null)
@@ -48,7 +59,8 @@ class ModeSettings(
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
val gpuDrivers: String = "Auto",
/** The drivers line; null shows Auto. */
val gpuDrivers: String? = null,
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val upscaler: Int = 0,
@@ -66,7 +78,7 @@ class ModeSettings(
val mic: Boolean?,
val renderer: String?,
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = emptyList(),
val storageOptions: List<com.droiddeck.launcher.session.GameStorage.Option> = emptyList(),
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
val storageDiagnostics: Boolean = false,
val fexPreset: String? = null,
@@ -103,6 +115,8 @@ class ModeSettings(
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
/** The stage is the Decky plugin's binary download. */
val deckyDownloadingBinary: Boolean = false,
val deckyEnabled: Boolean = false,
val deckySessionRunning: Boolean = false,
)
@@ -217,7 +231,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, s.upscaler,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
@@ -227,7 +241,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
@@ -239,7 +253,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
}
}
}
@@ -271,10 +285,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
@@ -349,11 +363,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onChange = a.onDeckyEnabled,
)
SettingsRow(
"Plugins",
"Install a plugin ZIP for the next Steam session.",
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage?.startsWith("Downloading plugin binary") == true) "Downloading…" else "Install from ZIP",
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
@@ -472,12 +486,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
val internal = stringResource(R.string.mode_storage_internal)
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
add("off" to stringResource(R.string.mode_storage_internal))
for ((label, path) in s.storageOptions) add(path to label)
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
add(StorageChoice(
"", if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto),
stringResource(R.string.mode_storage_auto_short), "",
))
add(StorageChoice("off", internal, internal, ""))
for (o in s.storageOptions) add(StorageChoice(o.path, o.label, o.label, o.name))
if (custom) stringResource(R.string.mode_storage_folder, s.gameStorage).let { add(StorageChoice(s.gameStorage, it, it, it)) }
}
val open = host.open == "storage"
SettingsRow(
@@ -486,14 +506,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
highlighted = open,
) {
androidx.compose.foundation.layout.Box {
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
ValueChip(options.firstOrNull { it.path == s.gameStorage }?.chip ?: "-", open) { host.open = if (open) null else "storage" }
AnchoredMenu(
open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library),
note = stringResource(R.string.mode_storage_note),
) { firstItemFocus ->
options.forEachIndexed { index, (path, label) ->
MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(path, if (path.isEmpty() || path == "off") "" else label.substringBefore(" ·"))
options.forEachIndexed { index, choice ->
MenuItem(choice.label, checked = choice.path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(choice.path, choice.saved)
host.open = null
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import android.view.KeyEvent
import androidx.compose.animation.animateColorAsState
@@ -63,40 +64,42 @@ import androidx.compose.ui.unit.sp
* modifier for the next key, a second tap locks it, a third lets it go. A controller moves over the
* keys with the d-pad and presses with A.
*/
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null)
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0)
private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
private val ROWS: List<List<PcKey>> = listOf(
listOf(
k("Esc", 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
k("Ins", 110), k("Del", 111), k("Home", 102), k("End", 107), k("PgUp", 104), k("PgDn", 109),
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
),
listOf(
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
k("=", 13, shifted = "+"), k("⌫ Backspace", 14, 2f),
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
),
listOf(
k("Tab ⇥", 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
k("\\", 43, 1.5f, shifted = "|"),
),
listOf(
k("Caps", 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
k("Enter ⏎", 28, 2.25f),
k(R.string.pckb_key_enter, 28, 2.25f),
),
listOf(
k("⇧ Shift", 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
k("⇧ Shift", 54, 1.75f), k("↑", 103),
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
),
listOf(
k("Ctrl", 29, 1.5f), k("Super", 125, 1.25f), k("Alt", 56, 1.25f), k("Space", 57, 6.25f),
k("AltGr", 100, 1.25f), k("Ctrl", 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
),
)
@@ -171,6 +174,7 @@ fun PcKeyboard(
val state = mods[key.code] ?: MOD_OFF
val lit = state != MOD_OFF || (key.code == 58 && capsOn)
val label = when {
key.labelRes != 0 -> stringResource(key.labelRes)
shifted && key.shifted != null -> key.shifted
key.label.length == 1 && key.label[0].isLetter() && (shifted xor capsOn) -> key.label.uppercase()
else -> key.label
@@ -202,7 +202,7 @@ private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
SettingsGroup(stringResource(R.string.gate_status), compact = compact) {
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Text(PhantomProcessLimit.title(LocalContext.current, status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Body(stringResource(R.string.gate_polling))
}
}
@@ -226,22 +226,24 @@ class SecondScreenPresentation(
}
keys.addView(button)
}
key("Esc", "\u001b")
key("Tab", "\t")
key("Ctrl", "") { button ->
val ctrl = context.getString(R.string.pckb_key_ctrl)
val alt = context.getString(R.string.pckb_key_alt)
key(context.getString(R.string.pckb_key_esc), "\u001b")
key(context.getString(R.string.second_key_tab), "\t")
key(ctrl, "") { button ->
ctrlActive = !ctrlActive
button.text = if (ctrlActive) "Ctrl ✓" else "Ctrl"
button.text = if (ctrlActive) context.getString(R.string.second_key_on, ctrl) else ctrl
}
key("Alt", "") { button ->
key(alt, "") { button ->
altActive = !altActive
button.text = if (altActive) "Alt ✓" else "Alt"
button.text = if (altActive) context.getString(R.string.second_key_on, alt) else alt
}
key("↑", "\u001b[A")
key("↓", "\u001b[B")
key("←", "\u001b[D")
key("→", "\u001b[C")
key("Ctrl+C", "\u0003")
key("Ctrl+D", "\u0004")
key(context.getString(R.string.second_key_ctrl_c), "\u0003")
key(context.getString(R.string.second_key_ctrl_d), "\u0004")
keyStrip.addView(keys)
root.addView(keyStrip, LinearLayout.LayoutParams(-1, -2))
@@ -17,6 +17,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -108,31 +109,24 @@ private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
)
/**
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
* order, and the screen never steps back.
* The checklist stage a loading line's [topic] belongs to, -1 when it says nothing about the stage.
* The lines are the session script's "== STEP" milestones and the installer's own progress; they
* arrive in order, and the screen never steps back.
*/
private fun stageOf(step: String, steam: Boolean): Int {
val t = step.lowercase()
return when {
"linux runtime" in t -> 0
"starting the session" in t -> 1
!steam && "desktop" in t -> 1
steam && "starting the steam client" in t -> 3
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
else -> -1
}
}
/** Download and install lines are worth reading as they are; the script's own notes are not. */
private fun readableStep(step: String): Boolean {
val t = step.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
private fun stageOf(topic: LoadingState.Topic, steam: Boolean): Int = when (topic) {
LoadingState.Topic.RUNTIME -> 0
LoadingState.Topic.SESSION -> 1
LoadingState.Topic.DESKTOP -> if (steam) -1 else 1
LoadingState.Topic.STEAM_STARTING -> if (steam) 3 else -1
LoadingState.Topic.STEAM -> if (steam) 2 else -1
LoadingState.Topic.OTHER -> -1
}
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
/** What [step] is about, and whether it is a download or install line worth showing as it is (see [LoadingState]). */
topic: LoadingState.Topic, readable: Boolean,
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
/** An ended session's exit status and log path, shown small under the advice. */
endedDetail: String? = null,
@@ -147,12 +141,12 @@ fun LoadingOverlay(
var ball by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
val at = stageOf(step, steam)
LaunchedEffect(step, topic, steam) {
val at = stageOf(topic, steam)
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
@@ -400,7 +400,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, a.upscaler,
SessionPrefs.upscalerChoices(LocalContext.current), a.upscaler,
note = stringResource(R.string.display_filter_note),
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
@@ -441,7 +441,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
}
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
}
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
@@ -754,7 +754,7 @@ private fun ScreenEffectsGroup(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SettingsGroup(stringResource(R.string.drawer_effects)) {
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
val presets = ScreenEffectLooks.LOOKS.map { it.name to stringResource(it.label) }
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
@@ -832,7 +832,7 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
.controllerConfirm(onClick = pick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
Text(stringResource(mode.label), fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
RadioButton(selected, onClick = null,
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
}
@@ -849,9 +849,9 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
}
@@ -51,9 +51,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -67,6 +71,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
@@ -146,6 +151,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif
* [bumpers]); the keycaps are for touch and stay out of the d-pad's path. The row scrolls sideways
* when a narrow page cannot fit every tab.
*/
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
internal fun TabStrip(
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
@@ -154,11 +160,17 @@ internal fun TabStrip(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(12.dp)
// Tabs that overflow (long labels on a small phone) first close up; only what still does not
// fit scrolls. Once tight the row stays tight, so it cannot flip back and forth.
val scroll = rememberScrollState()
var tight by remember(tabs) { mutableStateOf(false) }
LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true }
val tabPadding = if (tight) 10.dp else 16.dp
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
Row(
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
.horizontalScroll(rememberScrollState()).padding(3.dp),
.horizontalScroll(scroll).padding(3.dp),
) {
tabs.forEachIndexed { i, label ->
val on = i == selected
@@ -166,17 +178,21 @@ internal fun TabStrip(
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pick = { onSelect(i) }
val tabShape = RoundedCornerShape(9.dp)
// The chosen tab scrolls fully into view, so one at the end is not left cut off.
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
if (on) LaunchedEffect(Unit) { reveal.bringIntoView() }
Text(
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier
.bringIntoViewRequester(reveal)
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = tabPadding, vertical = 12.dp),
)
}
}
@@ -269,7 +285,34 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
)
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content(firstItemFocus) }
// While entries remain below the fold the list fades out at the bottom over a down
// arrow, so a short screen (a phone in landscape) shows that the menu goes on.
val scroll = rememberScrollState()
Box(Modifier.weight(1f, fill = false)) {
Column(
Modifier
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
if (scroll.canScrollForward) {
val fade = 36.dp.toPx().coerceAtMost(size.height / 3)
drawRect(
androidx.compose.ui.graphics.Brush.verticalGradient(
listOf(Color.Black, Color.Transparent), startY = size.height - fade, endY = size.height,
),
topLeft = androidx.compose.ui.geometry.Offset(0f, size.height - fade),
size = androidx.compose.ui.geometry.Size(size.width, fade),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
}
.verticalScroll(scroll),
) { content(firstItemFocus) }
if (scroll.canScrollForward) Icon(
Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = colors.onSurfaceVariant,
modifier = Modifier.align(Alignment.BottomCenter).size(20.dp),
)
}
if (note != null) {
Spacer(Modifier.height(4.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
@@ -280,6 +323,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MenuItem(
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
@@ -291,9 +335,13 @@ fun MenuItem(
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shift by animateFloatAsState(if (hot) 2f else 0f, Motion.sp(0.5f), label = "miShift")
// A menu taller than the screen opens scrolled to the current choice, not past it.
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
if (checked) LaunchedEffect(Unit) { reveal.bringIntoView() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
.bringIntoViewRequester(reveal)
.graphicsLayer { translationX = shift.dp.toPx() }
.then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(RoundedCornerShape(8.dp))
@@ -185,7 +185,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } }
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
@@ -195,7 +195,7 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") }
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
}
}
@@ -222,7 +222,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
}
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
@@ -9,13 +10,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.session.SessionPrefs
class Palette(
val id: String, val label: String, val detail: String,
val id: String, @StringRes val label: Int, @StringRes val detail: Int,
val background: Color, val surface: Color, val surfaceVariant: Color, val line: Color, val line2: Color,
val onBackground: Color, val onSurfaceVariant: Color,
val primary: Color, val primary2: Color, val onPrimary: Color, val signal: Color,
@@ -30,28 +32,28 @@ object Themes {
val all: List<Palette> = listOf(
Palette(
GRAPHITE, "Graphite", "Graphite surfaces, with the logo's own orb blue for everything selected, focused or pressed.",
GRAPHITE, R.string.theme_graphite, R.string.theme_graphite_detail,
background = Color(0xFF0A0B0D), surface = Color(0xFF121417), surfaceVariant = Color(0xFF1A1D22), line = Color(0xFF262A31), line2 = Color(0xFF343A43),
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF9AA3AF),
primary = Color(0xFF1A9FFF), primary2 = Color(0xFF1487DB), onPrimary = Color(0xFF03111F), signal = Color(0xFF1A9FFF),
good = Color(0xFF4CD37F),
),
Palette(
PAPER, "Paper on black", "White is the primary, the icon's blue is the signal for rules, rings and dots.",
PAPER, R.string.theme_paper, R.string.theme_paper_detail,
background = Color(0xFF000000), surface = Color(0xFF0F0F10), surfaceVariant = Color(0xFF191A1C), line = Color(0xFF262729), line2 = Color(0xFF343638),
onBackground = Color(0xFFF9F9F9), onSurfaceVariant = Color(0xFF8E9196),
primary = Color(0xFFF9F9F9), primary2 = Color(0xFFD9DADD), onPrimary = Color(0xFF000000), signal = Color(0xFF136CE0),
good = Color(0xFF4CD37F),
),
Palette(
ICON_BLUE, "Icon blue", "Black ground, white type, the blue for everything selected, focused or pressed.",
ICON_BLUE, R.string.theme_icon_blue, R.string.theme_icon_blue_detail,
background = Color(0xFF050608), surface = Color(0xFF0E1116), surfaceVariant = Color(0xFF161B23), line = Color(0xFF1F2630), line2 = Color(0xFF2A3340),
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF8A93A0),
primary = Color(0xFF136CE0), primary2 = Color(0xFF0B4FB0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF136CE0),
good = Color(0xFF4CD37F),
),
Palette(
ELECTRIC, "Electric navy", "The blue brighter, on surfaces that lean navy instead of grey.",
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
@@ -15,6 +15,7 @@ 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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -104,25 +105,31 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp),
)
val checked = u.catalog?.checkedAt?.takeIf { it > 0 }
// The relative time comes capitalized to stand alone (Spanish "Hace 5 minutos"); here it
// ends a sentence.
if (!LocalNarrowPane.current) Text(
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked)) else stringResource(R.string.upd_not_checked),
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked).replaceFirstChar { it.lowercase() }) else stringResource(R.string.upd_not_checked),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.store_check_updates), busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
// Landscape has the width for channels and status side by side; a narrow pane stacks them.
if (LocalNarrowPane.current) {
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
// Landscape has the width for channels and status side by side; a narrow pane, or a small
// phone's (where each channel's name and date would be cut to a word), stacks them.
val stacked = LocalNarrowPane.current || maxWidth < 720.dp
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
if (stacked) {
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
}
}
}
}
@@ -179,7 +186,7 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
}
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
if (installBlock != null && offered) {
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
Text(installBlock.message(androidx.compose.ui.platform.LocalContext.current), fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
}
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
val installable = release?.apk != null && installBlock == null && !s.sessionRunning
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.R
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
@@ -195,13 +196,17 @@ object AppUpdates {
}
/** Why Android cannot install this published build over [me], before downloading anything. */
fun installBlock(r: Release, me: Installed = installed()): String? = when {
r.apk == null -> "This build has no download for this copy of DroidDeck."
r.apk.versionCode < me.versionCode ->
"Android won't install it over this build because its versionCode ${r.apk.versionCode} is below the installed ${me.versionCode}."
fun installBlock(r: Release, me: Installed = installed()): InstallBlock? = when {
r.apk == null -> InstallBlock(R.string.upd_block_no_apk)
r.apk.versionCode < me.versionCode -> InstallBlock(R.string.upd_block_version_code, r.apk.versionCode, me.versionCode)
else -> null
}
/** Why Android would refuse a build, as a line for the user. */
class InstallBlock(@androidx.annotation.StringRes val text: Int, private vararg val args: Any) {
fun message(context: Context): String = context.getString(text, *args)
}
fun follow(context: Context, catalog: Catalog?): Follow {
stored(context)?.let { return it }
val me = installed()
@@ -243,11 +248,13 @@ object AppUpdates {
/** One static request, rather than several unauthenticated GitHub API requests. */
fun refresh(context: Context): Catalog {
val checkedAt = System.currentTimeMillis()
val catalog = readPublishedCatalog(
JSONObject(get("$CATALOG_URL?checked=$checkedAt")),
context.packageName,
checkedAt,
)
val published = JSONObject(get(context, "$CATALOG_URL?checked=$checkedAt"))
// Its checks say why in English; the user gets that inside a sentence in the app's language.
val catalog = try {
readPublishedCatalog(published, context.packageName, checkedAt)
} catch (e: IOException) {
throw IOException(context.getString(R.string.appupd_catalog_rejected, e.message), e)
}
prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply()
return catalog
}
@@ -358,7 +365,7 @@ object AppUpdates {
return 0
}
private fun get(url: String): String {
private fun get(context: Context, url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
@@ -367,7 +374,7 @@ object AppUpdates {
c.setRequestProperty("Cache-Control", "no-cache")
try {
val code = c.responseCode
if (code != 200) throw IOException("The update catalog answered HTTP $code")
if (code != 200) throw IOException(context.getString(R.string.appupd_http, code))
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build
import android.provider.Settings
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import java.io.File
@@ -46,7 +47,7 @@ object SelfInstaller {
c.setRequestProperty("User-Agent", "DroidDeck-app")
c.instanceFollowRedirects = true
try {
if (c.responseCode != 200) throw IOException("The download answered HTTP ${c.responseCode}")
if (c.responseCode != 200) throw IOException(context.getString(R.string.selfinst_http, c.responseCode))
val total = c.contentLengthLong.takeIf { it > 0 } ?: apk.size
c.inputStream.use { input ->
FileOutputStream(target).use { out ->
@@ -64,10 +65,10 @@ object SelfInstaller {
}
}
if (apk.size > 0 && target.length() != apk.size) {
throw IOException("The download size didn't match the published APK and was discarded - try again")
throw IOException(context.getString(R.string.selfinst_size_mismatch))
}
if (!apk.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("The download didn't match its checksum and was discarded - try again")
throw IOException(context.getString(R.string.selfinst_checksum_mismatch))
}
validateDownloaded(context, target, apk)
return target
@@ -82,10 +83,10 @@ object SelfInstaller {
/** Inspect the actual downloaded APK before giving it to Android's installer. */
private fun validateDownloaded(context: Context, file: File, apk: AppUpdates.Apk) {
if (apk.packageName != context.packageName) {
throw IOException("The update is for Android package ${apk.packageName}, not ${context.packageName}")
throw IOException(context.getString(R.string.selfinst_wrong_package, apk.packageName, context.packageName))
}
if (!apk.signerSha256.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) {
throw IOException("The update metadata doesn't name DroidDeck's release signing key")
throw IOException(context.getString(R.string.selfinst_metadata_signer))
}
@Suppress("DEPRECATION")
val flags = if (Build.VERSION.SDK_INT >= 28) {
@@ -94,29 +95,29 @@ object SelfInstaller {
PackageManager.GET_SIGNATURES
}
val info = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
?: throw IOException("Android couldn't read the downloaded APK")
?: throw IOException(context.getString(R.string.selfinst_unreadable))
if (info.packageName != context.packageName) {
throw IOException("The downloaded APK is for ${info.packageName}, not ${context.packageName}")
throw IOException(context.getString(R.string.selfinst_apk_wrong_package, info.packageName, context.packageName))
}
@Suppress("DEPRECATION")
val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
if (code != apk.versionCode.toLong()) {
throw IOException("The downloaded APK says versionCode $code, not the published ${apk.versionCode}")
throw IOException(context.getString(R.string.selfinst_version_mismatch, code, apk.versionCode))
}
if (code < BuildConfig.VERSION_CODE.toLong()) {
throw IOException("Android won't install versionCode $code over the installed ${BuildConfig.VERSION_CODE}")
throw IOException(context.getString(R.string.selfinst_downgrade, code, BuildConfig.VERSION_CODE))
}
// Before Android 9 this lineage intentionally presents the public legacy test key. Android
// 9+ understands the v3 hand-over and must see DroidDeck's private release key as current.
if (Build.VERSION.SDK_INT >= 28) {
val signing = info.signingInfo ?: throw IOException("The downloaded APK has no signing information")
val signing = info.signingInfo ?: throw IOException(context.getString(R.string.selfinst_no_signing))
val digests = signing.apkContentsSigners.orEmpty().map { cert ->
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
.joinToString("") { "%02x".format(it) }
}
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
throw IOException("The downloaded APK isn't signed with DroidDeck's release key")
throw IOException(context.getString(R.string.selfinst_wrong_signer))
}
}
}
@@ -145,13 +146,12 @@ object SelfInstaller {
}
/** What went wrong, in words for the user, from PackageInstaller's status and message. */
fun describeFailure(status: Int, message: String?): String = when {
message?.contains("VERSION_DOWNGRADE") == true ->
"Android won't install an older version over a newer one. Stay on this build until the channel catches up."
fun describeFailure(context: Context, status: Int, message: String?): String = when {
message?.contains("VERSION_DOWNGRADE") == true -> context.getString(R.string.upd_fail_downgrade)
status == PackageInstaller.STATUS_FAILURE_CONFLICT || message?.contains("UPDATE_INCOMPATIBLE") == true ->
"This copy of DroidDeck is signed differently (a local build?), so Android won't update it in place."
status == PackageInstaller.STATUS_FAILURE_STORAGE -> "There isn't enough free space for the update."
status == PackageInstaller.STATUS_FAILURE_ABORTED -> "The update was cancelled."
else -> "The update didn't install" + (message?.let { ": $it" } ?: ".")
context.getString(R.string.upd_fail_signature)
status == PackageInstaller.STATUS_FAILURE_STORAGE -> context.getString(R.string.upd_fail_storage)
status == PackageInstaller.STATUS_FAILURE_ABORTED -> context.getString(R.string.upd_fail_aborted)
else -> message?.let { context.getString(R.string.upd_fail_with_message, it) } ?: context.getString(R.string.upd_fail_generic)
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build
import android.view.Display
import android.view.WindowManager
import com.droiddeck.launcher.R
/**
* What the panel says about HDR, read from android.view.Display: the gate the compositor's HDR10
@@ -21,14 +22,9 @@ class HdrProbe(
val ratio: Float,
val displayId: Int,
val name: String,
) {
/** Why HDR cannot be offered, or null when it can. */
val reason: String? = when {
Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a color space)"
!hdr10 -> if (formats.isEmpty()) "this display reports no HDR support" else "this display supports $formats, not HDR10"
else -> null
}
}
val reason: String?,
)
object HdrSupport {
fun probe(context: Context): HdrProbe {
@@ -38,14 +34,15 @@ object HdrSupport {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
}
if (display == null) return HdrProbe(false, "", 0f, 0f, 0f, false, -1f, -1, "none")
if (display == null) return HdrProbe(false, "", 0f, 0f, 0f, false, -1f, -1, "none", reason(context, false, ""))
val caps = try { display.hdrCapabilities } catch (e: Exception) { null }
val types = caps?.supportedHdrTypes ?: IntArray(0)
val formats = types.joinToString(", ") { typeName(it) }
val ratioAvailable = Build.VERSION.SDK_INT >= 34 && try { display.isHdrSdrRatioAvailable } catch (e: Exception) { false }
val ratio = if (ratioAvailable) try { display.hdrSdrRatio } catch (e: Exception) { -1f } else -1f
val hdr10 = types.contains(Display.HdrCapabilities.HDR_TYPE_HDR10)
return HdrProbe(
hdr10 = types.contains(Display.HdrCapabilities.HDR_TYPE_HDR10),
hdr10 = hdr10,
formats = formats,
maxLuminance = caps?.desiredMaxLuminance ?: 0f,
maxAverageLuminance = caps?.desiredMaxAverageLuminance ?: 0f,
@@ -54,9 +51,16 @@ object HdrSupport {
ratio = ratio,
displayId = display.displayId,
name = display.name ?: "display",
reason = reason(context, hdr10, formats),
)
}
private fun reason(context: Context, hdr10: Boolean, formats: String): String? = when {
Build.VERSION.SDK_INT < 29 -> context.getString(R.string.hdr_needs_android_10)
!hdr10 -> if (formats.isEmpty()) context.getString(R.string.hdr_none) else context.getString(R.string.hdr_not_hdr10, formats)
else -> null
}
private fun typeName(t: Int): String = when (t) {
Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "Dolby Vision"
Display.HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+887
View File
@@ -259,6 +259,8 @@
<string name="mode_controller_x360">Xbox 360 controller</string>
<string name="mode_controller_note">Deck: Quick Access button and gyro. Xbox 360: open Quick Access with Guide+A.</string>
<string name="mode_back">Back</string>
<string name="back_menu_then_qam">Menu → QAM</string>
<string name="back_qam_then_menu">QAM → Menu</string>
<string name="mode_session">Session</string>
<string name="mode_suspend">Background behavior</string>
<string name="mode_suspend_hint">When the app is hidden or the screen turns off.</string>
@@ -595,6 +597,10 @@
<string name="setup_offline">Offline mode</string>
<string name="setup_sign_in_first">Sign in to Steam first</string>
<string name="setup_launcher">Launcher</string>
<string name="setup_language">Language</string>
<string name="setup_language_hint">Steam uses it too, from the next time it starts.</string>
<string name="setup_language_system">System default (%1$s)</string>
<string name="setup_language_system_short">System default</string>
<string name="setup_app_scale">App scale</string>
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
<string name="setup_app_scale_default">%1$d%% (Default)</string>
@@ -1023,6 +1029,887 @@
<string name="game_file_folder">Choose a folder for frontend game files</string>
<string name="game_file_exported">Game files saved to %1$s</string>
<string name="game_frontend_files">Frontend files</string>
<!-- Launcher, session and menus -->
<!-- Launcher (MainActivity) -->
<string name="main_wait_runtime">Wait for the runtime operation to finish</string>
<string name="main_wait_proton_install">Wait for the compatibility tool install to finish</string>
<string name="main_wait_desktop_install">Wait for the desktop app install to finish</string>
<string name="main_wait_install_game">Wait for the install to finish before launching a game</string>
<string name="main_saves_close_session">Close the Steam session first, so the game can\'t save over the import</string>
<!-- Busy line while saves are imported; %1$s is the game's name -->
<string name="main_saves_importing">Importing into %1$s</string>
<!-- %1$d files, %2$s the save layout (e.g. GameHub zip), %3$s the game's name -->
<plurals name="main_saves_imported">
<item quantity="one">Imported %1$d file from the %2$s into %3$s</item>
<item quantity="other">Imported %1$d files from the %2$s into %3$s</item>
</plurals>
<plurals name="main_saves_imported_backup">
<item quantity="one">Imported %1$d file from the %2$s into %3$s. Old saves backed up to Download/DroidDeck/Saves/backups</item>
<item quantity="other">Imported %1$d files from the %2$s into %3$s. Old saves backed up to Download/DroidDeck/Saves/backups</item>
</plurals>
<string name="main_saves_pick_zip">Choose a save zip for %1$s</string>
<!-- Busy line while saves are exported; %1$s is the game's name -->
<string name="main_saves_exporting">Exporting %1$s</string>
<!-- %1$d files, %2$s the save layout (e.g. GameHub zip), %3$s the zip's path -->
<plurals name="main_saves_exported">
<item quantity="one">Exported %1$d file as a %2$s: %3$s</item>
<item quantity="other">Exported %1$d files as a %2$s: %3$s</item>
</plurals>
<!-- %1$s is the game's name, %2$s the save layout -->
<string name="main_saves_pick_folder">Choose where to save %1$s (%2$s)</string>
<!-- %1$s is what was being done (e.g. Importing into Portal 2, Swapping), %2$s the error -->
<string name="main_action_failed">%1$s failed: %2$s</string>
<string name="main_no_logs_yet">No session logs yet: run a session first.</string>
<string name="main_no_logs_to_clear">No session logs to clear.</string>
<plurals name="main_logs_cleared">
<item quantity="one">Cleared %1$d session log folder.</item>
<item quantity="other">Cleared %1$d session log folders.</item>
</plurals>
<string name="main_wadb_pair_failed">Wireless debugging pairing failed</string>
<string name="main_wadb_command_failed">Wireless debugging command failed</string>
<string name="main_wadb_unavailable">Wireless debugging is unavailable</string>
<string name="main_adb_copied">ADB command copied</string>
<string name="main_pick_roms">Choose the ROMs folder</string>
<string name="main_non_adreno_title">Not an Adreno GPU</string>
<!-- %1$s is the GPU's name, %2$.0f the download size in MB -->
<string name="main_non_adreno_text">Turnip supports Adreno GPUs. On %1$s, Steam may show a black screen. Download: %2$.0f MB.</string>
<string name="main_install_anyway">Install anyway</string>
<string name="main_remove_runtime_title">Remove Linux runtime</string>
<string name="main_remove_runtime_text">This deletes the runtime, the Steam client inside it, and every game installed there.</string>
<!-- Toast; %1$s is a list of components, separated by commas -->
<string name="main_applied">Applied: %1$s</string>
<string name="android_app_open_failed_primary">Could not open %1$s on the primary screen</string>
<!-- %1$s is the app, %2$s the name of the display -->
<string name="android_app_open_failed_on">Could not open %1$s on %2$s</string>
<!-- %1$s is the app, %2$d the display's number -->
<string name="android_app_open_failed_display">Could not open %1$s on display %2$d</string>
<!-- Components page busy lines, shown with … after them -->
<string name="comp_busy_importing">Importing</string>
<string name="comp_busy_swapping">Swapping</string>
<string name="comp_busy_restoring">Restoring</string>
<string name="comp_busy_cancelling">Cancelling</string>
<string name="comp_busy_deleting">Deleting</string>
<string name="comp_cannot_read">cannot read the file</string>
<!-- %1$s is a version or a name -->
<string name="comp_imported">Imported %1$s</string>
<string name="comp_swap_cancelled">The waiting swap was cancelled.</string>
<string name="comp_pick_wcp">Choose a component package (-linux .wcp)</string>
<string name="comp_nightlies_unreachable">The Nightlies could not be reached</string>
<!-- %1$s is the package version -->
<string name="comp_stored">Stored %1$s</string>
<string name="comp_download_failed">Download failed: %1$s</string>
<string name="drivers_pick_linux">Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)</string>
<string name="drivers_pick_android">Choose a display driver (AdrenoTools zip or Android + Linux bundle)</string>
<string name="drivers_pick_any">Choose a driver zip or an Android + Linux bundle</string>
<!-- %1$s is a desktop app's name, %2$s what went wrong -->
<string name="main_pkg_problem">%1$s: %2$s</string>
<string name="main_removing_named">Removing %1$s…</string>
<string name="main_forgetting_named">Forgetting %1$s…</string>
<string name="main_pick_game_storage">Choose the game storage folder</string>
<string name="main_pick_added_games">Choose a folder of your own games</string>
<string name="main_pick_game_exe">Choose the game\'s .exe</string>
<string name="main_pick_decky_zip">Choose a Decky plugin ZIP</string>
<string name="main_storage_not_usable">Not usable: %1$s</string>
<!-- What is running, when it is a program without a known name -->
<string name="main_running_program">Program</string>
<string name="main_removing_runtime">Removing Linux runtime</string>
<string name="main_settings_main_screen">Could not open Settings on the bottom screen; opened it on the main screen.</string>
<string name="main_open_dev_options">Open Developer options in Settings</string>
<!-- Updates -->
<string name="upd_unknown_apps_failed">Couldn\'t open Android\'s \"Install unknown apps\" setting</string>
<string name="upd_couldnt_check">Couldn\'t check: %1$s</string>
<string name="upd_no_download">This build has no download for this copy of DroidDeck</string>
<string name="upd_stop_first">Stop the running session first, then update</string>
<!-- Update stages, shown with … after them -->
<string name="upd_stage_downloading">Downloading</string>
<string name="upd_stage_installing">Installing</string>
<string name="upd_stage_confirm">Waiting for you to confirm</string>
<string name="upd_failed">The update failed</string>
<string name="upd_not_offered">Android didn\'t offer to install the update</string>
<!-- Decky -->
<string name="decky_install_failed">Decky install failed: %1$s</string>
<string name="decky_plugin_starting">Starting plugin import…</string>
<string name="decky_plugin_failed">Plugin import failed: %1$s</string>
<string name="decky_plugin_installed">Plugin installed. Restart Steam to load it.</string>
<!-- GPU drivers -->
<string name="drivers_not_checked">Not checked yet - tap refresh to look for new drivers</string>
<string name="drivers_bundle_sets_display">Android + Linux: sets the display driver with it</string>
<string name="drivers_bundle_sets_runtime">Android + Linux: sets the runtime driver with it</string>
<string name="drivers_runtime_builtin">the Turnip built into the runtime</string>
<string name="drivers_system_vulkan">system Vulkan: no bundled build for this GPU</string>
<string name="drivers_bundled">%1$s (bundled)</string>
<string name="drivers_manual">Manual</string>
<!-- Driver mode, then the driver pair in use -->
<string name="drivers_summary">%1$s · %2$s</string>
<string name="drivers_half_published">Only one half is published right now</string>
<string name="drivers_pair_size">%1$.0f MB for both</string>
<!-- %1$s says why (the GPU's support status) -->
<string name="drivers_none_to_set">No drivers to set: %1$s</string>
<string name="drivers_checking_latest">Checking for the latest drivers…</string>
<string name="drivers_up_to_date_check_failed">Up to date (couldn\'t check: %1$s)</string>
<string name="drivers_recommended_unpublished">The recommended drivers aren\'t published right now</string>
<!-- %1$s is the driver pair's name, %2$s its version -->
<string name="drivers_downloading_pair">Downloading %1$s %2$s…</string>
<string name="drivers_updated_to">Updated to %1$s %2$s.</string>
<string name="drivers_updated_to_restart">Updated to %1$s %2$s. The display driver applies after DroidDeck restarts.</string>
<string name="drivers_switched_to">Switched to %1$s %2$s.</string>
<string name="drivers_switched_to_restart">Switched to %1$s %2$s. The display driver applies after DroidDeck restarts.</string>
<string name="drivers_using">Using %1$s %2$s.</string>
<string name="drivers_using_restart">Using %1$s %2$s. The display driver applies after DroidDeck restarts.</string>
<string name="drivers_not_a_driver">Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle</string>
<string name="drivers_import_failed">Import failed: %1$s</string>
<string name="drivers_imported_unnamed">Imported driver</string>
<string name="drivers_using_bundle">Using %1$s as both the runtime and display driver.</string>
<string name="drivers_using_bundle_restart">Using %1$s as both the runtime and display driver. The display driver applies after DroidDeck restarts.</string>
<string name="drivers_deleted_bundle">Deleted %1$s (both drivers)</string>
<string name="drivers_deleted">Deleted %1$s</string>
<!-- %1$s lists the latest releases, %2$s when they were checked (e.g. 5 min ago) -->
<string name="drivers_release_status">Latest: %1$s · checked %2$s</string>
<!-- %1$s lists the latest releases, %2$s the sources that could not be reached, %3$s when they were checked -->
<string name="drivers_release_status_failed">Latest: %1$s · %2$s unreachable · checked %3$s</string>
<plurals name="drivers_ago_minutes">
<item quantity="one">%1$d min ago</item>
<item quantity="other">%1$d min ago</item>
</plurals>
<plurals name="drivers_ago_hours">
<item quantity="one">%1$d h ago</item>
<item quantity="other">%1$d h ago</item>
</plurals>
<plurals name="drivers_ago_days">
<item quantity="one">%1$d day ago</item>
<item quantity="other">%1$d days ago</item>
</plurals>
<string name="drivers_checking_sources">Checking Banners-Turnip, WinNative and DroidDeck…</string>
<string name="drivers_check_failed">check failed</string>
<!-- %1$s is the driver's name -->
<string name="drivers_installed_pick_either">Installed %1$s - pick it in either list to set both drivers</string>
<string name="drivers_installed_pick">Installed %1$s - pick it in the menu</string>
<!-- Session (SessionActivity) -->
<string name="session_wait_removal">Wait for runtime removal to finish</string>
<!-- Drawer title when the running program has no known name -->
<string name="session_title_game">Game</string>
<!-- Shown as "Auto (touchpad)" in the drawer's touch setting -->
<string name="session_touch_auto_touchpad">touchpad</string>
<!-- Shown as "Auto (direct)" in the drawer's touch setting -->
<string name="session_touch_auto_direct">direct</string>
<string name="session_swap_failed">Swap failed: %1$s</string>
<string name="session_desktop_paused">The desktop is paused</string>
<!-- %1$s is the program's name -->
<string name="session_named_paused">%1$s is paused</string>
<string name="session_program_paused">The program is paused</string>
<string name="session_starting_steam_desktop">Starting Steam on the desktop</string>
<string name="session_starting_desktop">Starting the desktop</string>
<!-- %1$s is the program's name -->
<string name="session_starting_named">Starting %1$s</string>
<string name="session_starting_program">Starting the program</string>
<string name="session_no_logs">No logs for this session.</string>
<string name="session_logs_archive_failed">Could not create the session log archive.</string>
<string name="session_logs_share_failed">Could not share the session logs.</string>
<string name="session_starting_session">Starting the session…</string>
<!-- Loading screen lines while packages download; %1$d and %2$d are megabytes -->
<string name="session_runtime_downloading">downloading the Linux runtime</string>
<string name="session_runtime_downloading_mb">downloading the Linux runtime · %1$d of %2$d MB</string>
<string name="session_runtime_checking">checking the Linux runtime</string>
<string name="session_runtime_unpacking">unpacking the Linux runtime</string>
<string name="session_desktop_downloading">downloading the desktop</string>
<string name="session_desktop_downloading_mb">downloading the desktop · %1$d of %2$d MB</string>
<string name="session_desktop_checking">checking the desktop</string>
<string name="session_desktop_unpacking">unpacking the desktop</string>
<string name="session_proton_downloading">downloading Proton Experimental (ARM64)</string>
<string name="session_proton_downloading_mb">downloading Proton Experimental (ARM64) · %1$d of %2$d MB</string>
<string name="session_proton_checking">checking Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">unpacking Proton Experimental (ARM64)</string>
<string name="session_runtime_catalog_unreachable">Could not reach the runtime catalog. Check the connection and press Play again.</string>
<string name="session_runtime_install_failed">The Linux runtime did not install. Check the runtime in Setup and press Play again.</string>
<string name="session_desktop_catalog_unreachable">Could not reach the desktop catalog. Check the connection and press Desktop again.</string>
<!-- %1$s says what went wrong -->
<string name="session_desktop_install_failed">The desktop did not install (%1$s). Check the connection and press Desktop again.</string>
<!-- %1$s is the program's name, or Steam -->
<string name="session_ended_named">%1$s stopped unexpectedly. Share the logs with a bug report, or try again.</string>
<string name="session_ended_program">The program stopped unexpectedly. Share the logs with a bug report, or try again.</string>
<string name="session_ended_desktop">The desktop stopped unexpectedly. Share the logs with a bug report, or try again.</string>
<!-- %1$d is the exit status, %2$s the log file's path -->
<string name="session_ended_detail">Exit status %1$d · %2$s</string>
<string name="session_ended_no_log">no log</string>
<!-- %1$d errors; %2$s, %3$s and %4$s are the Performance page and its two settings -->
<plurals name="session_hint_enosys">
<item quantity="one">The log shows %1$d \"Function not implemented\" error: proot\'s seccomp acceleration is failing a helper on this device. Try %2$s → \"%3$s\" (or \"%4$s\") and start again.</item>
<item quantity="other">The log shows %1$d \"Function not implemented\" errors: proot\'s seccomp acceleration is failing a helper on this device. Try %2$s → \"%3$s\" (or \"%4$s\") and start again.</item>
</plurals>
<!-- Screens, files, controller and store -->
<!-- Mode settings -->
<string name="mode_upscaler_hint">How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.</string>
<string name="mode_upscaler_note">The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.</string>
<string name="mode_decky_plugins">Plugins</string>
<string name="mode_decky_plugins_hint">Install a plugin ZIP for the next Steam session.</string>
<string name="mode_decky_plugin_downloading">Downloading…</string>
<string name="mode_decky_plugin_install_zip">Install from ZIP</string>
<!-- Store -->
<string name="store_cat_games">Games</string>
<string name="store_cat_audio_video">Audio &amp; Video</string>
<string name="store_cat_graphics">Graphics</string>
<string name="store_cat_internet">Internet</string>
<string name="store_cat_office">Office</string>
<string name="store_cat_developer">Developer</string>
<string name="store_cat_education">Education</string>
<string name="store_cat_science">Science</string>
<string name="store_cat_system">System</string>
<string name="store_cat_utilities">Utilities</string>
<string name="store_section_popular">Popular</string>
<string name="store_section_trending">Trending</string>
<!-- Store front-page section of recently added apps -->
<string name="store_section_new">New</string>
<!-- Store front-page section of recently updated apps -->
<string name="store_section_updated">Updated</string>
<string name="store_section_loading">loading…</string>
<string name="store_flatpak_setup">Flatpak setup</string>
<!-- %1$s is what failed (an app name or "Flatpak setup"), %2$s the error -->
<string name="store_problem">%1$s: %2$s</string>
<string name="store_updates_check_failed">Could not check Flathub for updates</string>
<!-- Library tiles -->
<!-- Subtitle of a game tile whose cover art was found -->
<string name="content_rom_installed">installed</string>
<!-- Subtitle of a game that is a folder rather than a single file -->
<string name="content_rom_folder">folder</string>
<!-- Launcher themes -->
<string name="theme_graphite">Graphite</string>
<string name="theme_graphite_detail">Graphite surfaces, with the logo\'s own orb blue for everything selected, focused or pressed.</string>
<string name="theme_paper">Paper on black</string>
<string name="theme_paper_detail">White is the primary, the icon\'s blue is the signal for rules, rings and dots.</string>
<string name="theme_icon_blue">Icon blue</string>
<string name="theme_icon_blue_detail">Black ground, white type, the blue for everything selected, focused or pressed.</string>
<string name="theme_electric">Electric navy</string>
<string name="theme_electric_detail">The blue brighter, on surfaces that lean navy instead of grey.</string>
<!-- PC keyboard key caps (keep them short; they label physical PC keys) -->
<string name="pckb_key_esc">Esc</string>
<string name="pckb_key_ins">Ins</string>
<string name="pckb_key_del">Del</string>
<string name="pckb_key_home">Home</string>
<string name="pckb_key_end">End</string>
<string name="pckb_key_pgup">PgUp</string>
<string name="pckb_key_pgdn">PgDn</string>
<string name="pckb_key_backspace">⌫ Backspace</string>
<string name="pckb_key_tab">Tab ⇥</string>
<string name="pckb_key_caps">Caps</string>
<string name="pckb_key_enter">Enter ⏎</string>
<string name="pckb_key_shift">⇧ Shift</string>
<string name="pckb_key_ctrl">Ctrl</string>
<string name="pckb_key_super">Super</string>
<string name="pckb_key_alt">Alt</string>
<string name="pckb_key_space">Space</string>
<string name="pckb_key_altgr">AltGr</string>
<!-- Terminal key strip on the second screen -->
<string name="second_key_tab">Tab</string>
<!-- A modifier key (%1$s, e.g. Ctrl) that is currently held -->
<string name="second_key_on">%1$s ✓</string>
<string name="second_key_ctrl_c">Ctrl+C</string>
<string name="second_key_ctrl_d">Ctrl+D</string>
<!-- Game file sync errors -->
<string name="game_sync_err_runtime">Install the runtime first</string>
<string name="game_sync_err_steam_library">Steam library is unavailable</string>
<string name="game_sync_err_previous_sd">Previous SD library is unavailable</string>
<string name="game_sync_err_game_storage">Game storage is unavailable</string>
<string name="game_sync_err_added_games">Added games folder is unavailable</string>
<string name="game_sync_err_manifests">Steam manifests are unavailable</string>
<!-- The systems RetroArch emulates, shown under its name -->
<string name="content_system_many">Many systems</string>
<!-- File manager -->
<string name="fm_drive_internal">Internal</string>
<string name="fm_drive_sd">SD card</string>
<string name="fm_drive_storage">Storage</string>
<string name="fm_select">Select</string>
<string name="fm_properties">Properties</string>
<string name="fm_favorite_add">Add to Favorites</string>
<string name="fm_favorite_remove">Remove from Favorites</string>
<string name="fm_rename">Rename</string>
<string name="fm_copy">Copy</string>
<string name="fm_cut">Cut</string>
<string name="fm_actions">Actions</string>
<string name="fm_favorites">Favorites</string>
<string name="fm_pin_current">Pin current folder</string>
<string name="fm_no_favorites">No favorites yet - pin a folder with its ⋮ menu to jump back here fast.</string>
<string name="fm_unpin_favorite">Remove from favorites</string>
<string name="fm_size_zero">0 bytes</string>
<!-- Size unit for sizes under 1 KB, shown after a number -->
<string name="fm_unit_bytes">bytes</string>
<string name="fm_prop_location">Location</string>
<string name="fm_prop_type">Type</string>
<string name="fm_prop_size">Size</string>
<string name="fm_prop_modified">Modified</string>
<string name="fm_type_folder">Folder</string>
<string name="fm_type_file">File</string>
<!-- File type in Properties; %1$s is the file extension in capitals, e.g. TXT -->
<string name="fm_type_ext_file">%1$s file</string>
<string name="fm_read_only">Read-only</string>
<string name="fm_read_only_hint">Stops games (and Wine) from overwriting or deleting this file.</string>
<string name="fm_read_only_failed">Couldn\'t change Read-only</string>
<string name="fm_hidden">Hidden</string>
<string name="fm_hidden_unsupported">This storage can\'t store the hidden flag.</string>
<string name="fm_hidden_hint">Marks the file hidden in Windows (Wine\'s hidden attribute).</string>
<string name="fm_hidden_failed">Couldn\'t change Hidden on this storage</string>
<string name="fm_done">Done</string>
<string name="fm_op_deleting">Deleting...</string>
<string name="fm_delete_failed">Delete failed</string>
<string name="fm_op_moving">Moving...</string>
<string name="fm_op_copying">Copying...</string>
<!-- %1$s is the file name -->
<string name="fm_op_moving_item">Moving - %1$s</string>
<string name="fm_op_copying_item">Copying - %1$s</string>
<!-- %1$d is the item number, %2$d the number of items, %3$s the file name -->
<string name="fm_op_moving_batch">Moving %1$d/%2$d - %3$s</string>
<string name="fm_op_copying_batch">Copying %1$d/%2$d - %3$s</string>
<!-- Paste result; %1$d items done, %2$d items failed -->
<string name="fm_paste_failed">%1$d done, %2$d failed</string>
<!-- Paste result; %1$d items done, %2$d items skipped -->
<string name="fm_paste_skipped">%1$d done, %2$d skipped</string>
<plurals name="fm_paste_moved">
<item quantity="one">%1$d item moved</item>
<item quantity="other">%1$d items moved</item>
</plurals>
<plurals name="fm_paste_copied">
<item quantity="one">%1$d item copied</item>
<item quantity="other">%1$d items copied</item>
</plurals>
<!-- %1$s is a file or folder name -->
<string name="fm_already_exists">\"%1$s\" already exists</string>
<string name="fm_op_renaming">Renaming...</string>
<string name="fm_rename_failed">Rename failed</string>
<string name="fm_op_creating_folder">Creating folder...</string>
<string name="fm_create_folder_failed">Could not create folder</string>
<string name="fm_new_folder">New Folder</string>
<string name="fm_folder_name">Folder name</string>
<string name="fm_create">Create</string>
<string name="fm_new_name">New name</string>
<string name="fm_delete_title">Delete?</string>
<!-- %1$s is a file or folder name -->
<string name="fm_delete_text">Delete \"%1$s\" permanently?</string>
<plurals name="fm_delete_items_title">
<item quantity="one">Delete %1$d item?</item>
<item quantity="other">Delete %1$d items?</item>
</plurals>
<string name="fm_cannot_undo">This can\'t be undone.</string>
<!-- After a list of the first five names in a delete confirmation -->
<plurals name="fm_and_more">
<item quantity="one">…and %1$d more</item>
<item quantity="other">…and %1$d more</item>
</plurals>
<!-- %1$d is the item number, %2$d the number of items, %3$s the file name -->
<string name="fm_op_deleting_batch">Deleting %1$d/%2$d - %3$s</string>
<plurals name="fm_delete_some_failed">
<item quantity="one">%1$d couldn\'t be deleted</item>
<item quantity="other">%1$d couldn\'t be deleted</item>
</plurals>
<string name="fm_conflict_merge_hint">Merge adds and replaces files inside the existing folder.</string>
<string name="fm_conflict_overwrite_hint">Overwrite replaces the existing file.</string>
<string name="fm_conflict_apply_all">Apply to all conflicts</string>
<string name="fm_conflict_merge">Merge</string>
<string name="fm_conflict_overwrite">Overwrite</string>
<string name="fm_conflict_keep_both">Keep both</string>
<string name="fm_conflict_skip">Skip</string>
<string name="fm_select_this_folder">Select this folder</string>
<string name="fm_back">Back</string>
<!-- %1$s is a storage name, e.g. SD card -->
<string name="fm_drive_not_readable">%1$s is mounted but not readable right now</string>
<string name="fm_list_view">List view</string>
<string name="fm_grid_view">Grid view</string>
<string name="fm_search">Search</string>
<string name="fm_sort">Sort</string>
<string name="fm_sort_name">Name</string>
<string name="fm_sort_date">Date modified</string>
<string name="fm_rows_comfortable">Comfortable rows</string>
<string name="fm_rows_compact">Compact rows</string>
<string name="fm_hide_hidden">Hide hidden files</string>
<string name="fm_show_hidden">Show hidden files</string>
<string name="fm_hide_favorites">Hide favorites</string>
<string name="fm_show_favorites">Show favorites</string>
<string name="fm_filter_folder">Filter this folder</string>
<!-- Free space on the storage being browsed; %1$s is a size, e.g. 12.50 GB -->
<string name="fm_free_space">%1$s free</string>
<string name="fm_selected_count">%1$d selected</string>
<!-- Selection bar: clear the selection -->
<string name="fm_select_none">None</string>
<!-- Selection bar: select every item -->
<string name="fm_select_all">All</string>
<string name="fm_paste">Paste</string>
<plurals name="fm_items">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<!-- %1$s is a file name or a number of items -->
<string name="fm_paste_here">Paste %1$s here</string>
<!-- %1$s is a file name or a number of items -->
<string name="fm_paste_move_here">Paste %1$s (move) here</string>
<string name="fm_rail_title">Files</string>
<string name="fm_rail_storage">STORAGE</string>
<!-- Rail section of shortcut folders (Downloads, ROMs, …) -->
<string name="fm_rail_quick">QUICK</string>
<string name="fm_rail_favorites">FAVORITES</string>
<string name="fm_rail_downloads">Downloads</string>
<!-- Downloads shortened to fit under an icon (about 8 characters) -->
<string name="fm_rail_downloads_short">Downlds</string>
<string name="fm_rail_roms">ROMs</string>
<string name="fm_rail_session_logs">Session logs</string>
<string name="fm_rail_pictures">Pictures</string>
<!-- %1$s is a folder name -->
<string name="fm_favorite_added">Added \"%1$s\" to Favorites</string>
<!-- %1$s is a folder name -->
<string name="fm_favorite_removed">Removed \"%1$s\" from Favorites</string>
<string name="fm_empty_directory">Empty directory</string>
<string name="fm_choose_folder">Choose a folder</string>
<string name="fm_choose_file">Choose a file</string>
<!-- On-screen controller tint colors -->
<string name="ctrl_tint_steam_blue">Steam blue</string>
<string name="ctrl_tint_sky">Sky</string>
<string name="ctrl_tint_white">White</string>
<string name="ctrl_tint_violet">Violet</string>
<string name="ctrl_tint_green">Green</string>
<string name="ctrl_tint_amber">Amber</string>
<string name="ctrl_tint_red">Red</string>
<string name="ctrl_tint_pink">Pink</string>
<!-- On-screen buttons that can be remapped -->
<string name="ctrl_button_a">A button</string>
<string name="ctrl_button_b">B button</string>
<string name="ctrl_button_x">X button</string>
<string name="ctrl_button_y">Y button</string>
<string name="ctrl_button_lb">Left bumper</string>
<string name="ctrl_button_rb">Right bumper</string>
<string name="ctrl_button_lt">Left trigger</string>
<string name="ctrl_button_rt">Right trigger</string>
<string name="ctrl_button_view">View button</string>
<string name="ctrl_button_menu">Menu button</string>
<!-- What a remapped on-screen button sends; letters and Steam are controller button names -->
<string name="ctrl_target_a" translatable="false">A</string>
<string name="ctrl_target_b" translatable="false">B</string>
<string name="ctrl_target_x" translatable="false">X</string>
<string name="ctrl_target_y" translatable="false">Y</string>
<string name="ctrl_target_lb" translatable="false">LB</string>
<string name="ctrl_target_rb" translatable="false">RB</string>
<string name="ctrl_target_lt" translatable="false">LT</string>
<string name="ctrl_target_rt" translatable="false">RT</string>
<string name="ctrl_target_l3" translatable="false">L3</string>
<string name="ctrl_target_r3" translatable="false">R3</string>
<string name="ctrl_target_steam" translatable="false">Steam</string>
<!-- Controller View button -->
<string name="ctrl_target_view">View</string>
<!-- Controller Menu button -->
<string name="ctrl_target_menu">Menu</string>
<string name="ctrl_target_up">D-pad up</string>
<string name="ctrl_target_down">D-pad down</string>
<string name="ctrl_target_left">D-pad left</string>
<string name="ctrl_target_right">D-pad right</string>
<!-- The on-screen button is not shown -->
<string name="ctrl_target_hidden">Hidden</string>
<string name="ctrl_editor_drag">Drag to move</string>
<string name="ctrl_editor_save">Save</string>
<!-- What the second screen shows during a session -->
<string name="second_mode_none">None</string>
<string name="second_mode_keyboard_trackpad">Keyboard + trackpad</string>
<string name="second_mode_terminal">Terminal</string>
<string name="second_mode_deck_controls">Deck grips + trackpads</string>
<!-- Runtime, updates and setup helpers -->
<!-- Linux runtime install / removal (LinuxRuntimeInstaller, RuntimeInstallService) -->
<string name="rtinst_removing">Removing Linux runtime</string>
<plurals name="rtinst_removing_entries">
<item quantity="one">Removing Linux runtime · %1$d entry</item>
<item quantity="other">Removing Linux runtime · %1$d entries</item>
</plurals>
<string name="rtinst_removed">Linux runtime removed</string>
<string name="rtinst_removal_failed">Could not finish removing the Linux runtime. Retry removal in Setup.</string>
<string name="rtinst_removing_leftovers">Removing runtime leftovers</string>
<string name="rtinst_downloading">Downloading</string>
<string name="rtinst_verifying">Verifying</string>
<string name="rtinst_extracting">Extracting</string>
<string name="rtinst_channel_description">Shows while the Linux runtime is installed or removed</string>
<string name="rtinst_title_installing">Installing the Linux runtime</string>
<string name="rtinst_title_removing">Removing the Linux runtime</string>
<!-- Hosted desktop and emulator packages (DesktopCatalog) -->
<string name="deskpkg_runtime_missing">The Linux runtime is not installed</string>
<!-- %1$s: package name -->
<string name="deskpkg_no_checksum">The catalog has no checksum for %1$s</string>
<string name="deskpkg_checksum_mismatch">Checksum mismatch - nothing was changed</string>
<!-- %1$s: package name -->
<string name="deskpkg_installing">Installing %1$s</string>
<string name="deskpkg_place_failed">Could not place the AppImage</string>
<string name="deskpkg_extract_failed">Extraction failed</string>
<string name="deskpkg_install_failed">Install failed</string>
<!-- Flatpak store operations (FlatpakManager) -->
<string name="flatpakmgr_setup_failed">Flatpak setup failed (exit %1$d)</string>
<string name="flatpakmgr_setup_unfinished">Flatpak setup did not finish</string>
<string name="flatpakmgr_busy">Another store task is running</string>
<string name="flatpakmgr_not_ready">Set up Flatpak first</string>
<string name="flatpakmgr_starting">Starting</string>
<string name="flatpakmgr_exit_status">Flatpak exited with status %1$d</string>
<!-- %1$s: an app or runtime name, e.g. "SuperTux" or "GNOME runtime 50" -->
<string name="flatpakmgr_installing">Installing %1$s</string>
<string name="flatpakmgr_updating">Updating %1$s</string>
<string name="flatpakmgr_removing">Removing %1$s</string>
<!-- %1$s: an app or runtime name; %2$d of %3$d: this step and the number of steps -->
<string name="flatpakmgr_installing_step">Installing %1$s (%2$d/%3$d)</string>
<string name="flatpakmgr_updating_step">Updating %1$s (%2$d/%3$d)</string>
<string name="flatpakmgr_removing_step">Removing %1$s (%2$d/%3$d)</string>
<!-- Names for Flatpak runtime parts, shown inside "Installing %1$s" -->
<string name="flatpakmgr_ref_translations">translations</string>
<string name="flatpakmgr_ref_graphics_drivers">graphics drivers</string>
<string name="flatpakmgr_ref_media_codecs">media codecs</string>
<!-- %1$s: runtime vendor, e.g. GNOME, KDE, Freedesktop -->
<string name="flatpakmgr_ref_runtime">%1$s runtime</string>
<!-- %1$s: runtime vendor, e.g. GNOME; %2$s: its version, e.g. 50 -->
<string name="flatpakmgr_ref_runtime_branch">%1$s runtime %2$s</string>
<!-- Imported AppImages (AppImageManager) -->
<string name="appimg_too_small">The file is too small to be an AppImage</string>
<!-- %1$s: the system's reason -->
<string name="appimg_unreadable">The file cannot be read: %1$s</string>
<string name="appimg_not_linux">This is not an AppImage (it is not a Linux program)</string>
<string name="appimg_wrong_arch">This AppImage is built for neither ARM64 (aarch64) nor x86 PCs</string>
<string name="appimg_not_appimage">This is a Linux program but not an AppImage</string>
<string name="appimg_wrong_type">Only type 2 AppImages can be imported (this one is type %1$d)</string>
<!-- %1$s: a size such as "3.2 GB" -->
<string name="appimg_no_space_import">Not enough free space: importing needs about %1$s</string>
<!-- %1$s: a size such as "3.2 GB" -->
<string name="appimg_no_space_update">Not enough free space: updating needs about %1$s</string>
<string name="appimg_import_failed">Import failed</string>
<string name="appimg_gone">That AppImage is gone</string>
<string name="appimg_update_failed">Update failed</string>
<!-- %1$s: file name -->
<string name="appimg_extracting">Extracting %1$s</string>
<string name="appimg_extract_failed">The AppImage could not be extracted</string>
<!-- %1$s: the last line the extractor printed -->
<string name="appimg_extract_failed_detail">The AppImage could not be extracted: %1$s</string>
<!-- Decky Loader and plugin installs (DeckyManager) -->
<string name="deckymgr_stop_session_loader">Stop the active session before installing Decky Loader</string>
<string name="deckymgr_download_failed">Decky Loader download failed</string>
<string name="deckymgr_size_mismatch">Decky Loader size did not match the release metadata</string>
<string name="deckymgr_checksum_download_failed">Could not download the release checksum</string>
<string name="deckymgr_checksum_missing">Release checksum is missing</string>
<string name="deckymgr_checksum_invalid">Release checksum is invalid</string>
<string name="deckymgr_checksum_mismatch">Decky Loader checksum mismatch</string>
<string name="deckymgr_not_elf">Release asset is not a compatible 64-bit Linux executable</string>
<string name="deckymgr_session_started_loader">A session started during the download; stop it before installing Decky Loader</string>
<string name="deckymgr_loader_not_executable">Could not mark PluginLoader executable</string>
<string name="deckymgr_cef_failed">Could not enable Steam CEF remote debugging</string>
<string name="deckymgr_stage_failed">Could not stage PluginLoader</string>
<string name="deckymgr_loader_preserve_failed">Could not preserve the existing PluginLoader</string>
<string name="deckymgr_loader_activate_failed">Could not activate the new PluginLoader</string>
<string name="deckymgr_stop_session_plugin">Stop the active session before installing a plugin</string>
<string name="deckymgr_loader_required">Install Decky Loader before importing a plugin</string>
<string name="deckymgr_zip_unreadable">Could not read the selected ZIP file</string>
<string name="deckymgr_plugins_dir_failed">Could not create the Decky plugins directory</string>
<string name="deckymgr_extracting_zip">Extracting plugin ZIP</string>
<string name="deckymgr_zip_one_folder">Plugin ZIP must contain one top-level plugin folder</string>
<string name="deckymgr_zip_missing_files">Plugin ZIP is missing plugin.json, package.json, or dist/index.js</string>
<string name="deckymgr_plugin_name_missing">Plugin name is missing</string>
<string name="deckymgr_session_started_plugin">A session started during installation; stop it before installing a plugin</string>
<string name="deckymgr_plugin_installed">Plugin installed · restart Steam to load it</string>
<string name="deckymgr_plugin_install_failed">Plugin ZIP installation failed</string>
<string name="deckymgr_zip_too_many">Plugin ZIP contains too many entries</string>
<string name="deckymgr_zip_invalid_path">Plugin ZIP contains an invalid path</string>
<string name="deckymgr_zip_duplicate_paths">Plugin ZIP contains duplicate paths</string>
<string name="deckymgr_zip_unsafe_path">Plugin ZIP contains an unsafe path</string>
<string name="deckymgr_folder_failed">Could not create plugin folder</string>
<string name="deckymgr_zip_too_large">Plugin ZIP expands beyond the 1 GiB limit</string>
<string name="deckymgr_bundled_not_executable">Could not mark bundled plugin binary executable</string>
<string name="deckymgr_remote_entry_invalid">Invalid remote_binary entry</string>
<string name="deckymgr_remote_name_invalid">Invalid remote binary name</string>
<string name="deckymgr_remote_url_https">Remote binary URL must use HTTPS</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_remote_hash_invalid">Remote binary %1$s has an invalid SHA-256 hash</string>
<string name="deckymgr_bin_dir_failed">Could not create plugin bin directory</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_downloading_binary">Downloading plugin binary · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_download_failed">Could not download plugin binary %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_checksum_mismatch">Plugin binary checksum mismatch · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_not_executable">Could not mark plugin binary executable · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_replace_failed">Could not replace plugin binary · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_install_failed">Could not install plugin binary · %1$s</string>
<string name="deckymgr_folder_taken">Plugin folder belongs to another plugin</string>
<string name="deckymgr_preserve_failed">Could not preserve the existing plugin</string>
<string name="deckymgr_activate_failed">Could not activate the imported plugin</string>
<string name="deckymgr_settings_failed">Could not update Decky plugin settings</string>
<string name="deckymgr_restore_failed">Could not restore the existing plugin</string>
<!-- Child-process limit change over Wireless debugging (WirelessAdbFix) -->
<string name="adbfix_save_failed">Could not save Wireless debugging identity</string>
<!-- %1$d: the command's exit status -->
<string name="adbfix_command_failed">ADB command failed (%1$d)</string>
<!-- %1$s: what Android answered -->
<string name="adbfix_not_confirmed">Android did not confirm the child-process limit was changed: %1$s</string>
<string name="adbfix_no_pairing">No Wireless debugging pairing is saved</string>
<string name="adbfix_enter_address">Enter the current Wireless debugging IP address &amp; Port</string>
<!-- Wireless debugging pairing notification (WirelessAdbPairingService) -->
<string name="adbpair_timed_out">Timed out waiting for the pairing pop-up. Try again from DroidDeck.</string>
<!-- %1$d: Android's error code -->
<string name="adbpair_discovery_failed_code">Could not look for the pairing pop-up (%1$d). Check that Wi-Fi is on.</string>
<!-- %1$s: the system's reason -->
<string name="adbpair_discovery_failed">Could not look for the pairing pop-up: %1$s</string>
<string name="adbpair_six_digits">Enter all 6 digits of the code</string>
<string name="adbpair_pairing">Pairing…</string>
<string name="adbpair_code_rejected">That code didn\'t work. Open the pairing pop-up again and enter its new code.</string>
<string name="adbpair_connecting">Paired. Connecting…</string>
<string name="adbpair_port_not_found">Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.</string>
<string name="adbpair_applying">Applying the setting…</string>
<string name="adbpair_command_failed">Wireless debugging command failed</string>
<string name="adbpair_channel_name">Wireless debugging setup</string>
<string name="adbpair_channel_description">Asks for the Wireless debugging pairing code while Settings is open</string>
<string name="adbpair_title_pair">Pair Wireless debugging</string>
<string name="adbpair_waiting_text">Turn on Wireless debugging, then tap “Pair device with pairing code”.</string>
<string name="adbpair_waiting_long">Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification.</string>
<!-- Label of the notification's reply field -->
<string name="adbpair_code_label">6-digit pairing code</string>
<string name="adbpair_code_hint">Tap Enter code and type the 6 digits shown in Settings.</string>
<string name="adbpair_title_code">Enter the Wi-Fi pairing code</string>
<string name="adbpair_title_retry">Try the code again</string>
<!-- Notification action button -->
<string name="adbpair_enter_code">Enter code</string>
<string name="adbpair_title_working">Setting up Steam</string>
<string name="adbpair_title_done">Steam is ready</string>
<string name="adbpair_done_text">Tap to go back to DroidDeck. You can turn Wireless debugging off now.</string>
<string name="adbpair_title_failed">Wireless debugging setup stopped</string>
<!-- DroidDeck's own updates (SelfInstaller, AppUpdates) -->
<string name="selfinst_http">The download answered HTTP %1$d</string>
<string name="selfinst_size_mismatch">The download size didn\'t match the published APK and was discarded - try again</string>
<string name="selfinst_checksum_mismatch">The download didn\'t match its checksum and was discarded - try again</string>
<!-- %1$s: the update's Android package name; %2$s: this app's -->
<string name="selfinst_wrong_package">The update is for Android package %1$s, not %2$s</string>
<string name="selfinst_metadata_signer">The update metadata doesn\'t name DroidDeck\'s release signing key</string>
<string name="selfinst_unreadable">Android couldn\'t read the downloaded APK</string>
<!-- %1$s: the APK's Android package name; %2$s: this app's -->
<string name="selfinst_apk_wrong_package">The downloaded APK is for %1$s, not %2$s</string>
<string name="selfinst_version_mismatch">The downloaded APK says versionCode %1$d, not the published %2$d</string>
<string name="selfinst_downgrade">Android won\'t install versionCode %1$d over the installed %2$d</string>
<string name="selfinst_no_signing">The downloaded APK has no signing information</string>
<string name="selfinst_wrong_signer">The downloaded APK isn\'t signed with DroidDeck\'s release key</string>
<string name="appupd_http">The update catalog answered HTTP %1$d</string>
<!-- Session, GPU and display -->
<!-- Session loading screen -->
<string name="loadstate_starting">Starting the session…</string>
<!-- %1$d minutes, %2$02d seconds since the session started -->
<string name="loadstate_elapsed">%1$d:%2$02d elapsed · still working</string>
<string name="loadstate_steam_update">Downloading the Steam client update · %1$d%%</string>
<!-- %1$s is the client's own download progress, as it prints it -->
<string name="loadstate_steam_client">Downloading the Steam client · %1$s</string>
<!-- Installing GE-Proton / proton-cachyos (%1$s is the tool's name, %2$s its release tag) -->
<string name="pextra_busy">A compatibility tool install is already running</string>
<string name="pextra_stop_session">Stop the active session before installing compatibility tools</string>
<string name="pextra_no_release">Could not find an ARM64 release for %1$s</string>
<string name="pextra_no_space">At least 4 GB free plus the download size is required to install %1$s</string>
<string name="pextra_downloading">Downloading %1$s %2$s</string>
<string name="pextra_retrying">Retrying download · attempt %1$d of %2$d</string>
<string name="pextra_interrupted">Download interrupted</string>
<string name="pextra_download_failed">Download failed; the partial file is kept so a retry can resume</string>
<string name="pextra_too_big">Download exceeded the release size and was deleted</string>
<string name="pextra_incomplete">Download was incomplete; the partial file is kept for a resumable retry</string>
<string name="pextra_verifying">Verifying download</string>
<string name="pextra_checksum_unreadable">Could not read the release checksum; try again</string>
<string name="pextra_no_checksum">%1$s %2$s publishes no checksum; nothing was installed</string>
<string name="pextra_checksum_mismatch">Checksum mismatch; the download was deleted</string>
<string name="pextra_session_started">A session started during the download; stop it before installing compatibility tools</string>
<string name="pextra_installing">Installing %1$s</string>
<!-- %2$d is the installer's exit status -->
<string name="pextra_install_failed">%1$s installation failed (exit %2$d)</string>
<string name="pextra_could_not_install">Could not install %1$s</string>
<string name="esync_fetching">Fetching droiddeck-esync pack</string>
<!-- Components (FEX / DXVK / VKD3D-Proton swapped into a Proton). %1$s component, %2$s version, %3$s Proton -->
<string name="cmgr_fex_linux_helpers">with Linux helpers</string>
<string name="cmgr_fex_dlls_only">DLLs only</string>
<string name="cmgr_original_proton_updated">Original (Proton updated since the last swap)</string>
<string name="cmgr_changed_outside">Changed outside Components</string>
<!-- A component version that could not be found in a Proton -->
<string name="cmgr_not_present">not present</string>
<!-- %1$s is the Proton build the original files were saved from -->
<string name="cmgr_original_version">Original %1$s</string>
<string name="cmgr_original_from">Original from %1$s</string>
<string name="cmgr_swap_queued">%1$s %2$s goes into %3$s when the game running on it closes.</string>
<string name="cmgr_swap_done">%1$s %2$s is now in %3$s. It applies the next time a game starts.</string>
<string name="cmgr_original_missing">That original bundle is not stored</string>
<!-- %1$s component, %2$s Proton -->
<string name="cmgr_restore_queued">%1$s of %2$s goes back to its original when the game running on it closes.</string>
<!-- %1$s component, %2$s Proton, %3$s the Proton build the original came from -->
<string name="cmgr_restore_done">%1$s of %2$s restored from its %3$s original.</string>
<!-- %1$s package file, %2$s Proton id -->
<string name="cmgr_package_in_use">%1$s is in use in %2$s. Swap that Proton to something else first.</string>
<string name="cmgr_package_not_stored">%1$s is not stored</string>
<string name="cmgr_deleted">Deleted %1$s.</string>
<string name="cmgr_original_kept">The installed build\'s original is kept: it is the way back.</string>
<string name="cmgr_not_stored">Not stored</string>
<!-- %1$s Proton build, %2$s component -->
<string name="cmgr_deleted_original">Deleted the %1$s original of %2$s.</string>
<string name="cmgr_nightlies_only">Downloads come only from the Nightlies releases</string>
<string name="cmgr_list_predates_checksums">This package list predates checksums - refresh it and try again</string>
<string name="cmgr_checksum_mismatch">Checksum mismatch</string>
<!-- Display choices -->
<string name="sprefs_shape_auto">Auto (16:9+)</string>
<string name="sprefs_shape_exact">Match screen</string>
<string name="sprefs_shape_wide">Always 16:9</string>
<string name="sprefs_upscaler_linear">Linear</string>
<string name="sprefs_upscaler_nearest">Nearest</string>
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1 (fit)</string>
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR (quality)</string>
<string name="sprefs_upscaler_sharpen">Sharpen only</string>
<string name="sprefs_lod_bias_auto">Auto (match scaling)</string>
<!-- Screen effect Looks: preset names and the line under the row -->
<string name="look_off_desc">Nothing applied.</string>
<string name="look_game_clarity">Game Clarity</string>
<string name="look_game_clarity_desc">Sharpening with a touch of contrast - the everyday one.</string>
<string name="look_vivid">Vivid</string>
<string name="look_vivid_desc">Colour pushed hard. Deband on so skies don\'t stripe.</string>
<string name="look_cinematic">Cinematic</string>
<string name="look_cinematic_desc">Deeper contrast, cooler colour, edges smoothed.</string>
<string name="look_competitive">Competitive</string>
<string name="look_competitive_desc">Shadows lifted and heavy sharpening - spot people first.</string>
<string name="look_adaptive_sharpen">Adaptive Sharpen</string>
<string name="look_adaptive_sharpen_desc">Sharpening only, colour untouched.</string>
<string name="look_filmic">Filmic</string>
<string name="look_filmic_desc">Film-like curve, slightly muted.</string>
<string name="look_arcade">Arcade</string>
<string name="look_arcade_desc">Loud and punchy.</string>
<string name="look_retro_crt">Retro CRT</string>
<string name="look_retro_crt_desc">The CRT tube and analogue signal passes together.</string>
<string name="look_upscale_sharp">Upscale Sharp</string>
<string name="look_upscale_sharp_desc">For running below panel resolution.</string>
<string name="look_pixel_clean">Pixel Clean</string>
<string name="look_pixel_clean_desc">Crisp pixels for 2D and old titles.</string>
<string name="look_anime_edge">Anime Edge</string>
<string name="look_anime_edge_desc">Toon outlines plus sharpening.</string>
<!-- GPU support line -->
<string name="gpuinfo_not_adreno">Not an Adreno GPU</string>
<string name="gpuinfo_supported">Supported</string>
<string name="gpuinfo_experimental">Experimental: its drivers are test builds</string>
<string name="gpuinfo_below_tested">Below tested hardware (Adreno 725 and newer): it may not run</string>
<string name="gpuinfo_unsupported">Not supported: DroidDeck needs an Adreno (Snapdragon) GPU</string>
<!-- Driver downloads and imports -->
<string name="turnip_rate_limited">GitHub\'s rate limit was hit - try again later</string>
<string name="turnip_github_http">GitHub answered HTTP %1$d</string>
<string name="turnip_download_http">download answered HTTP %1$d</string>
<string name="turnip_list_predates_checksums">This driver list predates checksums - refresh it and try again</string>
<string name="turnip_checksum_mismatch">Checksum mismatch - the download was discarded</string>
<!-- %1$s is one of the turnip_reject_* reasons -->
<string name="turnip_not_adrenotools">Not an AdrenoTools driver: %1$s</string>
<string name="turnip_reject_no_meta">no meta.json</string>
<string name="turnip_reject_meta_unreadable">meta.json is unreadable (%1$s)</string>
<string name="turnip_reject_linux_driver">this is a Linux runtime driver (import it under \"Linux runtime driver\")</string>
<string name="turnip_reject_wayland">this is a Wayland game driver, which this app has no use for</string>
<string name="turnip_reject_no_library_name">meta.json names no libraryName, so nothing could be handed to AdrenoTools</string>
<!-- %1$s is a library file name -->
<string name="turnip_reject_library_missing">meta.json names %1$s, which is not in the zip</string>
<string name="turnip_reject_not_aarch64">%1$s is not a 64-bit AArch64 ELF shared library</string>
<string name="turnip_reject_glibc">%1$s links glibc - a \"-Linux\" Turnip, which belongs under \"Linux runtime driver\"</string>
<string name="lvd_no_library">No libvulkan_freedreno*.so in this zip. An Android (AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.</string>
<string name="lvd_not_aarch64">%1$s is not a 64-bit AArch64 ELF shared library.</string>
<string name="lvd_not_glibc">%1$s is not a glibc driver - it links Android\'s libc. The Linux runtime needs a \"-Linux\" zip; a plain or \"-Wayland\" Turnip cannot be loaded by the Steam client at all.</string>
<string name="bundle_no_manifest">Not a driver bundle: no manifest.json naming an android and a linux driver</string>
<string name="bundle_entry_outside">Not a driver bundle: %1$s points outside it</string>
<string name="bundle_too_large">Not a driver bundle: it unpacks to more than %1$d MB</string>
<!-- Game storage -->
<string name="gstore_sd_card">SD card</string>
<!-- %1$s is a size, like 12.3 GB -->
<string name="gstore_folder">Folder</string>
<string name="logshare_chooser">Share session logs</string>
<!-- Stands in for the Steam account name when the client did not record one -->
<!-- Why the HDR switch is greyed out -->
<string name="hdr_needs_android_10">needs Android 10 or newer (display layers with a color space)</string>
<string name="hdr_none">this display reports no HDR support</string>
<!-- %1$s lists HDR formats, like Dolby Vision, HDR10+ -->
<string name="hdr_not_hdr10">this display supports %1$s, not HDR10</string>
<!-- Follow-up -->
<!-- Stands in for the GPU name when the device does not report one -->
<string name="gpu_this_gpu">this GPU</string>
<string name="phantom_title_on">Child-process limit is on</string>
<string name="phantom_title_unset">Child-process limit is unset</string>
<string name="phantom_title_unreadable">Child-process limit could not be checked</string>
<string name="phantom_title_off">Child-process limit is off</string>
<string name="phantom_title_not_required">Child-process limit not required</string>
<!-- %1$s is the sentence saying how to turn the limit off on this device -->
<string name="phantom_instructions_fix">%1$s If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again.</string>
<string name="phantom_instructions_off">Steam sessions can start.</string>
<string name="phantom_instructions_not_applicable">This Android version does not use this limit.</string>
<string name="phantom_fix_toggle">Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards.</string>
<string name="phantom_fix_no_toggle">This Android version has no switch for it. Use Wireless debugging to change it from this device.</string>
<string name="upd_block_no_apk">This build has no download for this copy of DroidDeck.</string>
<!-- %1$d is the offered build's versionCode, %2$d the installed one's -->
<string name="upd_block_version_code">Android won\'t install it over this build because its versionCode %1$d is below the installed %2$d.</string>
<string name="upd_fail_downgrade">Android won\'t install an older version over a newer one. Stay on this build until the channel catches up.</string>
<string name="upd_fail_signature">This copy of DroidDeck is signed differently (a local build?), so Android won\'t update it in place.</string>
<string name="upd_fail_storage">There isn\'t enough free space for the update.</string>
<string name="upd_fail_aborted">The update was cancelled.</string>
<!-- %1$s is Android's own (English) installer message -->
<string name="upd_fail_with_message">The update didn\'t install: %1$s</string>
<string name="upd_fail_generic">The update didn\'t install.</string>
<!-- A save zip's layout; %1$s is the app it comes from (GameHub, Winlator) -->
<string name="saves_layout_zip">%1$s zip</string>
<!-- A save zip whose layout is not known -->
<string name="saves_zip">zip</string>
<!-- %1$s is the game's name -->
<string name="saves_not_launched">%1$s has not been launched yet</string>
<string name="saves_none_found">No save files found for %1$s</string>
<!-- %1$s is the zip's file name -->
<string name="saves_not_save_zip">%1$s has no drive_c folder, so it isn\'t a GameHub or Winlator save zip</string>
<!-- %1$s is a folder path -->
<string name="gstore_cannot_create">cannot create folders in %1$s</string>
<string name="gstore_cannot_write">cannot write in %1$s</string>
<!-- %1$s is a folder path, %2$s the system's error -->
<string name="gstore_cannot_write_detail">cannot write in %1$s (%2$s)</string>
<string name="gfiles_folder_unavailable">Folder is unavailable</string>
<!-- %1$s is a file name -->
<string name="gfiles_name_taken">A different file already uses %1$s</string>
<string name="gfiles_art_unavailable">Artwork folder is unavailable</string>
<string name="gfiles_unrelated_inventory">Folder has an unrelated sync inventory</string>
<!-- %1$s is a file name -->
<string name="gfiles_remove_failed">Could not remove %1$s</string>
<string name="gfiles_outside_folder">File leaves the export folder</string>
<!-- A FEX build whose DLL carries no version name -->
<string name="cmgr_fex_unnamed">FEX (build without a version name)</string>
<!-- %1$s is a package file name -->
<string name="cmgr_no_profile">no profile.json in %1$s</string>
<!-- %1$s is a package file name, %2$s the type its profile.json gives -->
<string name="cmgr_not_component_package">%1$s is not a Linux component package (type %2$s)</string>
<!-- A storage volume in the second-library menu; %1$s is its name, %2$s its free space, like 12.3 GB -->
<string name="gstore_volume_free">%1$s · %2$s free</string>
<!-- The second-library setting's value when it is automatic (short form of mode_storage_auto) -->
<string name="mode_storage_auto_short">Automatic</string>
<!-- %1$s is the technical reason, kept in English -->
<string name="appupd_catalog_rejected">The update catalog failed its checks (%1$s)</string>
<!-- Label of the copied ADB command on the clipboard -->
<string name="main_adb_clip_label">DroidDeck child-process setting</string>
<!-- Signed in, but the Steam client recorded no account name -->
<string name="setup_signed_in_offline_unnamed">Signed in · offline mode</string>
<string name="setup_signed_in_unnamed">Signed in</string>
<!-- Small uppercase tags beside a driver's name: where it came from -->
<string name="driver_tag_bundled">BUNDLED</string>
<string name="driver_tag_downloaded">DOWNLOADED</string>
<string name="driver_tag_imported">IMPORTED</string>
<!-- The driver is one half of a bundle that sets both drivers -->
<string name="driver_tag_bundle">ANDROID + LINUX</string>
<!-- %1$s is the limit's state (e.g. Child-process limit is on), %2$s what to do, %3$s the ADB command -->
<string name="phantom_warning">%1$s. %2$s\n\n%3$s</string>
<string name="display_session">Session display</string>
<string name="display_resolution">Resolution</string>
<string name="display_game_fit">16:9 game fit</string>
+7 -2
View File
@@ -1,7 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The languages DroidDeck ships, for Android's per-app language setting. -->
<!-- The languages DroidDeck ships, for Android's per-app language setting. Keep in step with
AppLanguage.supported and the values-* folders. -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="es" />
<locale android:name="zh" />
<locale android:name="ja" />
<locale android:name="ko" />
<locale android:name="zh-CN" />
<locale android:name="zh-TW" />
<locale android:name="zh-HK" />
</locale-config>
@@ -22,12 +22,13 @@ import java.io.File
@Config(sdk = [33])
class GameFilesTest {
@get:Rule val tmp = TemporaryFolder()
private val app get() = RuntimeEnvironment.getApplication()
private fun game(id: String = "8400", name: String = "Geometry Wars") =
Library.SteamGame(8400, name, null, "internal", id.toULong().toLong())
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
val folder = tmp.newFolder()
val file = GameFiles.export(folder, game("18446744073709551615", "A/B: C"))
val file = GameFiles.export(app, folder, game("18446744073709551615", "A/B: C"))
assertEquals("A_B_ C (18446744073709551615).droiddeck", file.name)
assertEquals("droiddeck://game/18446744073709551615\n", file.readText())
val context = RuntimeEnvironment.getApplication()
@@ -61,20 +62,20 @@ class GameFilesTest {
val art = tmp.newFile("cover.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) }
val icon = tmp.newFile("icon.jpg").apply { writeBytes(byteArrayOf(4, 5, 6)) }
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", art, "internal", icon = icon)
GameFiles.export(folder, game)
GameFiles.export(app, folder, game)
val image = File(folder, "images/geometry-wars-retro-evolved-icon.jpg")
assertArrayEquals(icon.readBytes(), image.readBytes())
LibraryCache.save(RuntimeEnvironment.getApplication(), listOf(game))
assertEquals(icon, LibraryCache.load(RuntimeEnvironment.getApplication()).single().icon)
image.writeText("custom artwork")
GameFiles.sync(folder, listOf(game))
GameFiles.sync(app, folder, listOf(game))
assertEquals("custom artwork", image.readText())
GameFiles.sync(folder, emptyList())
GameFiles.sync(app, folder, emptyList())
assertEquals("custom artwork", image.readText())
assertEquals(0, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
icon.delete()
val fallback = tmp.newFolder()
GameFiles.export(fallback, game)
GameFiles.export(app, fallback, game)
assertArrayEquals(art.readBytes(), File(fallback, "images/${image.name}").readBytes())
}
@@ -84,20 +85,20 @@ class GameFilesTest {
val note = File(folder, "notes.txt").apply { writeText("Keep me") }
val first = game()
val second = game("620", "Portal 2")
GameFiles.sync(folder, listOf(first, second))
GameFiles.sync(app, folder, listOf(first, second))
val firstFile = File(folder, GameFiles.filename(first))
val secondFile = File(folder, GameFiles.filename(second))
val modified = secondFile.apply { writeText("user edit") }
val third = game("1", "New install")
GameFiles.sync(folder, listOf(first, second, third))
GameFiles.sync(app, folder, listOf(first, second, third))
assertTrue(File(folder, GameFiles.filename(third)).isFile)
assertEquals("user edit", modified.readText())
val renamed = game(name = "Geometry Wars: Retro Evolved")
GameFiles.sync(folder, listOf(renamed))
GameFiles.sync(app, folder, listOf(renamed))
assertFalse(firstFile.exists())
assertTrue(File(folder, GameFiles.filename(renamed)).isFile)
assertEquals("user edit", modified.readText())
GameFiles.sync(folder, emptyList())
GameFiles.sync(app, folder, emptyList())
assertFalse(File(folder, GameFiles.filename(renamed)).exists())
assertTrue(unrelated.isFile)
assertEquals("Keep me", note.readText())
@@ -107,14 +108,14 @@ class GameFilesTest {
@Test fun failedExportDoesNotPruneOldInventoryAndNeverOverwritesCollisions() {
val folder = tmp.newFolder()
val first = game()
GameFiles.sync(folder, listOf(first))
GameFiles.sync(app, folder, listOf(first))
val second = game("620", "Portal 2")
val collision = File(folder, GameFiles.filename(second)).apply { writeText("personal file") }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(second)) }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(app, folder, listOf(second)) }
assertEquals("personal file", collision.readText())
assertTrue(File(folder, GameFiles.filename(first)).isFile)
collision.delete()
GameFiles.sync(folder, listOf(second))
GameFiles.sync(app, folder, listOf(second))
assertFalse(File(folder, GameFiles.filename(first)).exists())
assertTrue(collision.isFile)
}
@@ -123,7 +124,7 @@ class GameFilesTest {
val folder = tmp.newFolder()
val games = listOf(game(name = "../.."), game(name = "🎮".repeat(200)), game(name = "\u0000\n"))
games.forEach { game ->
val file = GameFiles.export(folder, game)
val file = GameFiles.export(app, folder, game)
assertEquals(folder.canonicalFile, file.canonicalFile.parentFile)
assertTrue(file.name.toByteArray().size < 255)
}
@@ -134,7 +135,7 @@ class GameFilesTest {
val outside = tmp.newFile()
outside.writeText("droiddeck://game/8400\n")
java.nio.file.Files.createSymbolicLink(File(folder, GameFiles.filename(game())).toPath(), outside.toPath())
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(folder, game()) }
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(app, folder, game()) }
assertEquals("droiddeck://game/8400\n", outside.readText())
}
@@ -142,8 +143,8 @@ class GameFilesTest {
val folder = tmp.newFolder()
val first = game(name = "Game")
val renamed = game(name = "GAME")
GameFiles.sync(folder, listOf(first))
GameFiles.sync(folder, listOf(renamed))
GameFiles.sync(app, folder, listOf(first))
GameFiles.sync(app, folder, listOf(renamed))
val file = File(folder, GameFiles.filename(renamed))
assertTrue(file.isFile)
assertEquals("8400", file.inputStream().use(GameFiles::read))
@@ -153,7 +154,7 @@ class GameFilesTest {
@Test fun unrelatedInventoryIsPreserved() {
val folder = tmp.newFolder()
val inventory = File(folder, ".droiddeck-sync.json").apply { writeText("{}") }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(game())) }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(app, folder, listOf(game())) }
assertEquals("{}", inventory.readText())
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.R
import java.io.File
import java.nio.file.Files
import org.junit.After
@@ -30,15 +31,15 @@ class AppImageManagerTest {
@Test fun acceptsAnI386ImageForFex() = assertNull(AppImageManager.problem(image(3)))
@Test fun refusesOtherProcessors() = assertTrue(AppImageManager.problem(image(40))!!.contains("neither"))
@Test fun refusesOtherProcessors() = assertTrue(AppImageManager.problem(image(40))!!.text == R.string.appimg_wrong_arch)
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2"))
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.text == R.string.appimg_wrong_type)
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage"))
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.text == R.string.appimg_not_appimage)
@Test fun refusesSomethingElse() {
val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") }
assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage"))
assertTrue(AppImageManager.problem(f)!!.text == R.string.appimg_not_linux)
}
@Test fun idsAreTidyAndUnique() {
@@ -53,7 +53,7 @@ class DeckyPluginInstallTest {
try {
DeckyPluginInstall.activate(plugin(temporary.newFolder(), "Unifideck", "Unifideck"), plugins, "Unifideck", settings, ".test")
fail("Expected conflicting identity to fail")
} catch (_: IllegalArgumentException) {}
} catch (_: DeckyPluginInstall.Failure) {}
assertEquals("{}", settings.readText())
assertEquals("Other", JSONObject(File(plugins, "Unifideck/plugin.json").readText()).getString("name"))
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.runtime
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GuestFontsTest {
private fun region(tag: String) = GuestFonts.cjkRegion(Locale.forLanguageTag(tag))
@Test fun sharedCharactersTakeTheReadersRegionalShapes() {
assertEquals("JP", region("ja"))
assertEquals("KR", region("ko"))
assertEquals("SC", region("zh-CN"))
assertEquals("TC", region("zh-TW"))
assertEquals("HK", region("zh-HK"))
assertEquals("HK", region("zh-MO"))
assertEquals("SC", region("zh-Hans-HK"))
assertEquals("TC", region("zh-Hant"))
assertEquals("SC", region("en"))
}
@Test fun latinStaysOnDejaVuWithTheRegionsCjkNext() {
val conf = GuestFonts.preferenceConf(Locale.JAPANESE)
val sans = conf.substringAfter("<family>sans-serif</family>").substringBefore("</alias>")
assertTrue(sans, sans.indexOf("DejaVu Sans") in 0 until sans.indexOf("Noto Sans CJK JP"))
assertTrue(conf.contains("<family>Noto Serif CJK JP</family>"))
assertTrue(conf.contains("<family>Noto Sans Mono CJK JP</family>"))
assertTrue(conf.startsWith("<?xml"))
}
}
@@ -77,12 +77,12 @@ class RuntimeRemovalTest {
@Test fun reservationBlocksPlayAndInstallBeforeTheWorkerStartsAndFollowersSeeProgress() {
val root = runtime()
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
assertTrue(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.isRemoving())
assertFalse(LinuxRuntimeInstaller.isInstalling())
assertFalse(LinuxRuntime.isInstalled(null))
assertNull(LinuxRuntimeInstaller.beginUninstall(root))
assertNull(LinuxRuntimeInstaller.beginUninstall(null, root))
assertFalse(LinuxRuntimeInstaller.install(null, null, null))
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
@@ -94,10 +94,10 @@ class RuntimeRemovalTest {
try {
assertTrue(entered.await(5, TimeUnit.SECONDS))
val following = CountDownLatch(1)
val stages = mutableListOf<String>()
val percents = mutableListOf<Int>()
val followerResult = AtomicReference<Boolean>()
val follower = Thread {
followerResult.set(LinuxRuntimeInstaller.attach { stage, _ -> stages.add(stage); following.countDown() })
followerResult.set(LinuxRuntimeInstaller.attach { _, percent -> percents.add(percent); following.countDown() })
}
follower.start()
assertTrue(following.await(5, TimeUnit.SECONDS))
@@ -106,7 +106,8 @@ class RuntimeRemovalTest {
follower.join(5000)
assertEquals(true, result.get())
assertEquals(true, followerResult.get())
assertEquals("Linux runtime removed", stages.last())
// The last stage is "Linux runtime removed", the only one reported at 100.
assertEquals(100, percents.last())
assertFalse(root.exists())
assertFalse(File(dir, "linuxfs.removing").exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
@@ -120,7 +121,7 @@ class RuntimeRemovalTest {
val tree = File(dir, name).apply { mkdirs() }
File(tree, "game").writeText("staged")
}
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!
assertTrue(removal.run(null))
assertTrue(dir.listFiles()!!.isEmpty())
}
@@ -128,9 +129,11 @@ class RuntimeRemovalTest {
@Test fun aPartialRemovalStaysQuarantinedAndRetryable() {
val root = runtime()
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
assertFalse(removal.run { stage, _ ->
if (stage.contains("entries"))
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
// The first stage names the removal, the second counts its first entry.
var stages = 0
assertFalse(removal.run { _, _ ->
if (++stages == 2)
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
})
assertFalse(root.exists())
@@ -138,7 +141,7 @@ class RuntimeRemovalTest {
assertFalse(LinuxRuntimeInstaller.isBusy())
assertNotNull(LinuxRuntimeInstaller.removalError())
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertFalse(File(dir, "linuxfs.removing").exists())
}
@@ -146,15 +149,15 @@ class RuntimeRemovalTest {
val root = runtime()
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
val stages = mutableListOf<String>()
assertFalse(removal.run { stage, _ -> stages.add(stage) })
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
val percents = mutableListOf<Int>()
assertFalse(removal.run { _, percent -> percents.add(percent) })
assertTrue(root.exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.removalError()!!.contains("Retry removal"))
assertFalse(stages.contains("Linux runtime removed"))
assertNotNull(LinuxRuntimeInstaller.removalError())
assertFalse(percents.contains(100))
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertNull(LinuxRuntimeInstaller.removalError())
}
}
@@ -31,7 +31,7 @@ class RuntimeRemovalUiTest {
}
@Test fun directSessionLaunchDuringRemovalFinishesWithoutStartingTheGuest() {
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!
try {
val activity = Robolectric.buildActivity(SessionActivity::class.java).create()
assertTrue(activity.get().isFinishing)
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.session
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SteamLanguageTest {
private fun steam(tag: String, region: String? = null) = SteamLanguage.forLocale(Locale.forLanguageTag(tag), region)
@Test fun theAppsLanguagesMapToSteams() {
assertEquals("english", steam("en"))
assertEquals("spanish", steam("es"))
assertEquals("japanese", steam("ja"))
assertEquals("koreana", steam("ko"))
assertEquals("schinese", steam("zh-CN"))
assertEquals("tchinese", steam("zh-TW"))
assertEquals("tchinese", steam("zh-HK"))
}
@Test fun chineseFollowsTheScriptBeforeTheRegion() {
assertEquals("tchinese", steam("zh-Hant-CN"))
assertEquals("schinese", steam("zh-Hans-HK"))
assertEquals("schinese", steam("zh"))
assertEquals("schinese", steam("zh-SG"))
assertEquals("tchinese", steam("zh-MO"))
}
@Test fun spanishInTheAmericasIsLatinAmerican() {
assertEquals("latam", steam("es-MX"))
assertEquals("latam", steam("es-419"))
assertEquals("spanish", steam("es-ES"))
// The app's Spanish has no region; the system's says where the player is.
assertEquals("latam", steam("es", region = "AR"))
assertEquals("spanish", steam("es", region = "ES"))
}
@Test fun systemLanguagesTheAppDoesNotShipStillReachSteam() {
assertEquals("german", steam("de-DE"))
assertEquals("brazilian", steam("pt-BR"))
assertEquals("portuguese", steam("pt-PT"))
assertEquals("norwegian", steam("nb-NO"))
assertEquals("indonesian", steam("in-ID"))
assertEquals("ukrainian", steam("uk-UA"))
}
@Test fun anythingElseIsEnglish() {
assertEquals("english", steam("sw-KE"))
assertEquals("english", steam("und"))
}
@Test fun everyAnswerIsALanguageSteamHas() {
Locale.getAvailableLocales().forEach { assertTrue(it.toLanguageTag(), SteamLanguage.forLocale(it) in SteamLanguage.all) }
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.store
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.FlatpakManager
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -11,10 +12,17 @@ class FlathubApiTest {
}
@Test fun refsReadAsWords() {
assertEquals("SuperTux", FlatpakManager.refLabel("app/org.supertuxproject.SuperTux/aarch64/stable"))
assertEquals("GNOME runtime 50", FlatpakManager.refLabel("runtime/org.gnome.Platform/aarch64/50"))
assertEquals("Freedesktop runtime 26.08", FlatpakManager.refLabel("runtime/org.freedesktop.Platform/aarch64/26.08"))
assertEquals("graphics drivers", FlatpakManager.refLabel("runtime/org.freedesktop.Platform.GL.default/aarch64/26.08"))
assertEquals("translations", FlatpakManager.refLabel("runtime/org.gnome.Platform.Locale/aarch64/50"))
// The words come from string resources; stand in for them with their English.
val english = mapOf(
R.string.flatpakmgr_ref_runtime_branch to "%1\$s runtime %2\$s",
R.string.flatpakmgr_ref_graphics_drivers to "graphics drivers",
R.string.flatpakmgr_ref_translations to "translations",
)
fun label(ref: String) = FlatpakManager.refLabel(ref) { id, args -> english.getValue(id).format(*args) }
assertEquals("SuperTux", label("app/org.supertuxproject.SuperTux/aarch64/stable"))
assertEquals("GNOME runtime 50", label("runtime/org.gnome.Platform/aarch64/50"))
assertEquals("Freedesktop runtime 26.08", label("runtime/org.freedesktop.Platform/aarch64/26.08"))
assertEquals("graphics drivers", label("runtime/org.freedesktop.Platform.GL.default/aarch64/26.08"))
assertEquals("translations", label("runtime/org.gnome.Platform.Locale/aarch64/50"))
}
}
@@ -708,6 +708,10 @@ BUSCONF
# and a restart below (rc=42, or the one after the ARM64 Proton lands) would otherwise start it
# again on the branch it just wrote.
steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta}
# The app's language (Setup, or the system's): the client's interface takes -language over its
# registry, and the registry is rewritten before each start below for its update window.
_bl_lang=()
[ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE")
steam_beta=$steam_root/package/beta
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
@@ -731,7 +735,7 @@ BUSCONF
done
) &
fi
echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
@@ -780,8 +784,12 @@ BUSCONF
echo start >> "$steam_root/package/droiddeck-unconfirmed"
_bl_counted=1
fi
# Between starts only: the client holds registry.vdf in memory and writes it back on exit.
if [ -n "${BL_STEAM_LANGUAGE:-}" ]; then
droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" -clientbeta "$steam_channel" \
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
rc=$?
@@ -0,0 +1,181 @@
#!/usr/bin/python3
"""Sets the Steam client's interface language to the app's, before the client starts.
droiddeck-steam-language <home> <language>
<language> is one of the client's language names (english, spanish, latam, japanese, koreana,
schinese, tchinese, ...). The client reads it from <home>/.steam/registry.vdf, under
HKCU/Software/Valve/Steam and again under .../Steamsteamglobal: that second key is copied over the
first at every start, so setting only the first is undone by the old value. Its update window
reads the first key alone, and nothing reads LANG. exportedsettings.json is what the client writes
beside them when its own setting changes. The session also passes -language, which the interface
honours over the file; the file is still what the update window shows itself in.
Run only while the client is stopped: it keeps the registry in memory and writes it back on exit.
"""
import json
import os
import re
import sys
LANGUAGES = {
"english", "german", "french", "italian", "koreana", "spanish", "schinese", "tchinese",
"russian", "thai", "japanese", "portuguese", "polish", "danish", "dutch", "finnish",
"norwegian", "swedish", "hungarian", "czech", "romanian", "turkish", "brazilian", "bulgarian",
"greek", "arabic", "ukrainian", "latam", "vietnamese", "indonesian", "malay",
}
KEYS = (("Registry", "HKCU", "Software", "Valve", "Steam"),
("Registry", "HKCU", "Software", "Valve", "Steamsteamglobal"))
TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)')
def tokenize(text):
tokens, pos = [], 0
while pos < len(text):
m = TOKEN.match(text, pos)
if not m or m.end() == pos:
break
pos = m.end()
if m.group(2):
tokens.append(m.group(2))
elif m.group(1) is not None:
tokens.append('"' + m.group(1) + '"')
return tokens
def serialize(tokens):
out, depth, i = [], 0, 0
while i < len(tokens):
t = tokens[i]
if t == "}":
depth -= 1
out.append("\t" * depth + "}")
i += 1
elif i + 1 < len(tokens) and tokens[i + 1] == "{":
out.append("\t" * depth + t)
out.append("\t" * depth + "{")
depth += 1
i += 2
else:
out.append("\t" * depth + t + "\t\t" + (tokens[i + 1] if i + 1 < len(tokens) else '""'))
i += 2
return "\n".join(out) + "\n"
def block_end(tokens, start):
"""Index of the '}' closing the block whose '{' is at start."""
depth = 0
for i in range(start, len(tokens)):
if tokens[i] == "{":
depth += 1
elif tokens[i] == "}":
depth -= 1
if depth == 0:
return i
return len(tokens)
def child(tokens, start, end, name):
"""Index of the '{' of the block called name directly inside tokens[start:end], or None.
Keys are matched without case, as the client does."""
i = start
while i < end:
if tokens[i] in ("{", "}"):
i += 1
continue
if i + 1 < end and tokens[i + 1] == "{":
if tokens[i].lower() == '"%s"' % name.lower():
return i + 1
i = block_end(tokens, i + 1) + 1
else:
i += 2
return None
def ensure_block(tokens, path):
"""The '{' of the block at path, creating it and any missing parent at the end of its parent."""
start, end = 0, len(tokens)
for depth, name in enumerate(path):
found = child(tokens, start, end, name)
if found is None:
at = end
tokens[at:at] = ['"%s"' % name, "{", "}"]
found = at + 1
start, end = found + 1, block_end(tokens, found)
return start - 1
def set_value(tokens, opening, key, value):
start, end = opening + 1, block_end(tokens, opening)
i = start
while i < end:
if i + 1 < end and tokens[i + 1] == "{":
i = block_end(tokens, i + 1) + 1
continue
if tokens[i].lower() == '"%s"' % key.lower() and i + 1 < end:
tokens[i + 1] = '"%s"' % value
return
i += 2
tokens[end:end] = ['"%s"' % key, '"%s"' % value]
def with_language(text, language):
tokens = tokenize(text)
if child(tokens, 0, len(tokens), "Registry") is None:
# Not a registry the client wrote (or none at all): start one it will read.
tokens = []
for path in KEYS:
set_value(tokens, ensure_block(tokens, path), "language", language)
return serialize(tokens)
def write_changed(path, text):
try:
with open(path, encoding="utf-8", errors="replace") as f:
if f.read() == text:
return False
except OSError:
pass
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".droiddeck"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
return True
def apply(home, language):
steam = os.path.join(home, ".steam")
registry = os.path.join(steam, "registry.vdf")
try:
with open(registry, encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
text = ""
changed = write_changed(registry, with_language(text, language))
exported = os.path.join(steam, "exportedsettings.json")
try:
with open(exported, encoding="utf-8") as f:
settings = json.load(f)
if not isinstance(settings, dict):
settings = {}
except (OSError, ValueError):
settings = {}
if settings.get("language") != language:
settings["language"] = language
write_changed(exported, json.dumps(settings))
changed = True
return changed
def main(argv):
if len(argv) != 3 or argv[2] not in LANGUAGES:
print("usage: droiddeck-steam-language <home> <steam language name>", file=sys.stderr)
return 2
changed = apply(argv[1], argv[2])
print("== steam: language %s%s" % (argv[2], "" if changed else " (already set)"))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+138
View File
@@ -0,0 +1,138 @@
import json
from pathlib import Path
import runpy
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-language'))
apply = MODULE['apply']
with_language = MODULE['with_language']
tokenize = MODULE['tokenize']
child = MODULE['child']
block_end = MODULE['block_end']
main = MODULE['main']
def value(text, *path):
tokens = tokenize(text)
start, end = 0, len(tokens)
for name in path[:-1]:
opening = child(tokens, start, end, name)
if opening is None:
return None
start, end = opening + 1, block_end(tokens, opening)
i = start
while i < end:
if i + 1 < end and tokens[i + 1] == '{':
i = block_end(tokens, i + 1) + 1
continue
if tokens[i].lower() == '"%s"' % path[-1].lower():
return tokens[i + 1].strip('"')
i += 2
return None
STEAM = ('Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'language')
GLOBAL = ('Registry', 'HKCU', 'Software', 'Valve', 'Steamsteamglobal', 'language')
# As the client leaves it on a device (trimmed): both keys, other values beside them, and HKLM.
CLIENT = '''"Registry"
{
"HKLM"
{
"Software"
{
"Valve"
{
"Steam"
{
"InstallPath" "/root/.local/share/Steam"
}
}
}
}
"HKCU"
{
"Software"
{
"Valve"
{
"Steam"
{
"language" "english"
"AutoLoginUser" "someone"
"Apps"
{
"language" "not this one"
}
}
"Steamsteamglobal"
{
"language" "english"
}
}
}
}
}
'''
class SteamLanguageTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.home = Path(self.temp.name)
(self.home / '.steam').mkdir()
self.registry = self.home / '.steam/registry.vdf'
def test_both_keys_change_and_everything_else_stays(self):
text = with_language(CLIENT, 'japanese')
self.assertEqual('japanese', value(text, *STEAM))
self.assertEqual('japanese', value(text, *GLOBAL))
self.assertEqual('someone', value(text, 'Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'AutoLoginUser'))
self.assertEqual('not this one', value(text, 'Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'Apps', 'language'))
self.assertEqual('/root/.local/share/Steam', value(text, 'Registry', 'HKLM', 'Software', 'Valve', 'Steam', 'InstallPath'))
self.assertEqual(1, text.count('"HKCU"'))
def test_a_first_run_gets_a_registry_the_client_reads(self):
self.assertTrue(apply(str(self.home), 'koreana'))
text = self.registry.read_text()
self.assertTrue(text.startswith('"Registry"'))
self.assertEqual('koreana', value(text, *STEAM))
self.assertEqual('koreana', value(text, *GLOBAL))
self.assertEqual({'language': 'koreana'}, json.loads((self.home / '.steam/exportedsettings.json').read_text()))
def test_missing_keys_are_added_whatever_their_case(self):
text = with_language('"registry"\n{\n\t"hkcu"\n\t{\n\t\t"software"\n\t\t{\n\t\t}\n\t}\n}\n', 'schinese')
self.assertEqual('schinese', value(text, *STEAM))
self.assertEqual('schinese', value(text, *GLOBAL))
self.assertEqual(1, text.lower().count('"hkcu"'))
self.assertEqual(1, text.lower().count('"software"'))
def test_a_value_is_replaced_not_duplicated_whatever_its_key_case(self):
text = with_language(CLIENT.replace('"language"\t\t"english"', '"Language"\t\t"english"'), 'latam')
self.assertEqual(2, text.lower().count('"language"\t\t"latam"'))
self.assertNotIn('english', text)
def test_an_unchanged_language_writes_nothing(self):
self.registry.write_text(CLIENT)
apply(str(self.home), 'spanish')
before = self.registry.stat().st_mtime_ns
self.assertFalse(apply(str(self.home), 'spanish'))
self.assertEqual(before, self.registry.stat().st_mtime_ns)
def test_other_exported_settings_are_kept(self):
(self.home / '.steam/exportedsettings.json').write_text('{"language": "english", "other": 1}')
apply(str(self.home), 'tchinese')
self.assertEqual({'language': 'tchinese', 'other': 1}, json.loads((self.home / '.steam/exportedsettings.json').read_text()))
def test_only_the_clients_names_are_accepted(self):
self.assertEqual(2, main(['x', str(self.home), 'korean']))
self.assertEqual(2, main(['x', str(self.home), 'Spanish']))
self.assertFalse(self.registry.exists())
if __name__ == '__main__':
unittest.main()