Merge branch 'wip/l10n-g4' into feat/language-selection

This commit is contained in:
maxjivi05 committed 2026-10-04 22:34:40 -04:00
commit bd458667db
16 files changed
+328 -105

No files matched your search

@@ -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,6 +56,13 @@ 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
})
companion object {
fun detect(): GpuInfo {
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
@@ -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) {
@@ -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
@@ -415,23 +416,23 @@ object ComponentsManager {
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 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 +440,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
/**
@@ -512,12 +523,12 @@ object ComponentsManager {
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 +544,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) {
@@ -599,22 +610,22 @@ 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). */
@@ -662,13 +673,13 @@ 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") }
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(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)
}
}
}
@@ -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
@@ -26,8 +27,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("$name · " + context.getString(R.string.gstore_free, free(dir)), File(dir, "steam").absolutePath)
}
}
@@ -66,5 +67,5 @@ object GameStorage {
/** 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" }
?: File(path).name.ifEmpty { context.getString(R.string.gstore_folder) }
}
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets
*/
class LoadingState(context: Context, 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))
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
@@ -31,12 +31,13 @@ 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()]
}
@@ -101,9 +102,9 @@ class LoadingState(context: Context, steam: Boolean = true) {
}
return when {
downloadAt > stepAt && downloadPercent >= 0 ->
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
Pair(context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent)
clientDownloadAt == stepAt && clientDownload != null ->
Pair("Downloading the Steam client · $clientDownload", clientPercent)
Pair(context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent)
stepText != null -> Pair(stepText, -1)
else -> null
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
@@ -39,7 +40,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() } ?: context.getString(R.string.offline_signed_in)
}
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)
}
}
@@ -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,7 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
@@ -95,6 +96,16 @@ object SessionPrefs {
SHAPE_WIDE to "Always 16:9",
)
/** [shapeChoices] labelled in the app's language. */
fun shapeChoices(context: Context): List<Pair<String, String>> = shapeChoices.map { (mode, label) ->
mode to when (mode) {
SHAPE_AUTO -> context.getString(R.string.sprefs_shape_auto)
SHAPE_EXACT -> context.getString(R.string.sprefs_shape_exact)
SHAPE_WIDE -> context.getString(R.string.sprefs_shape_wide)
else -> label
}
}
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
@@ -667,6 +678,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): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
@@ -678,6 +693,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.frame_gen_off, 1 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
5 to R.string.sprefs_upscaler_fsr_fit, 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 upscaler(context: Context): Int =
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
@@ -705,6 +730,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"
@@ -713,6 +742,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
@@ -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"
+144
View File
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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_free">%1$s free</string>
<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 -->
<string name="offline_signed_in">signed in</string>
<!-- 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>
</resources>