From 59010f1def359af52077fc03a73e19d37ffcfd80 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Sun, 4 Oct 2026 22:34:18 -0400 Subject: [PATCH] l10n: move runtime, update and core manager UI text into string resources (#212) --- .../droiddeck/launcher/core/WirelessAdbFix.kt | 22 ++- .../core/WirelessAdbPairingService.kt | 53 +++-- .../launcher/runtime/AppImageManager.kt | 46 +++-- .../launcher/runtime/DeckyManager.kt | 120 ++++++----- .../launcher/runtime/DeckyPluginInstall.kt | 15 +- .../launcher/runtime/DesktopCatalog.kt | 24 +-- .../launcher/runtime/FlatpakManager.kt | 59 +++--- .../runtime/LinuxRuntimeInstaller.java | 77 +++++--- .../launcher/runtime/RuntimeInstallService.kt | 8 +- .../droiddeck/launcher/update/AppUpdates.kt | 7 +- .../launcher/update/SelfInstaller.kt | 23 +-- app/src/main/res/values/strings_g3.xml | 186 ++++++++++++++++++ .../launcher/runtime/AppImageManagerTest.kt | 9 +- .../runtime/DeckyPluginInstallTest.kt | 2 +- .../launcher/runtime/RuntimeRemovalTest.kt | 35 ++-- .../launcher/runtime/RuntimeRemovalUiTest.kt | 2 +- .../launcher/store/FlathubApiTest.kt | 18 +- 17 files changed, 486 insertions(+), 220 deletions(-) create mode 100644 app/src/main/res/values/strings_g3.xml diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt index c97ba3a86..573aea35a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt index f4a005086..f8806c7c0 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt @@ -38,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 @@ -78,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 { @@ -110,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() } @@ -177,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) @@ -196,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) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt index 50e851db1..59f4f7804 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt @@ -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 = @@ -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 = 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, 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 diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 7203751e5..60058b72c 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -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, val prerelease: List) 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() @@ -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)) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyPluginInstall.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyPluginInstall.kt index 1affde9dd..b4223b7af 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyPluginInstall.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyPluginInstall.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index da69edf43..b077ec710 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index 6d7be215e..bd606f786 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -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) -> 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('.') } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 0d4f0595f..0598adf2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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 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; diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index 9eb0ff81e..639726bfb 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -30,7 +30,7 @@ class RuntimeInstallService : Service() { 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 @@ -56,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) @@ -68,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) diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 8e8376f81..5ca054ead 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -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 @@ -244,7 +245,7 @@ object AppUpdates { fun refresh(context: Context): Catalog { val checkedAt = System.currentTimeMillis() val catalog = readPublishedCatalog( - JSONObject(get("$CATALOG_URL?checked=$checkedAt")), + JSONObject(get(context, "$CATALOG_URL?checked=$checkedAt")), context.packageName, checkedAt, ) @@ -358,7 +359,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 +368,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() diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt index cf69b45aa..97dd807d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt @@ -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)) } } } diff --git a/app/src/main/res/values/strings_g3.xml b/app/src/main/res/values/strings_g3.xml new file mode 100644 index 000000000..5dd2b4204 --- /dev/null +++ b/app/src/main/res/values/strings_g3.xml @@ -0,0 +1,186 @@ + + + + Removing Linux runtime + + Removing Linux runtime · %1$d entry + Removing Linux runtime · %1$d entries + + Linux runtime removed + Could not finish removing the Linux runtime. Retry removal in Setup. + Removing runtime leftovers + Downloading + Verifying + Extracting + Shows while the Linux runtime is installed or removed + Installing the Linux runtime + Removing the Linux runtime + + + The Linux runtime is not installed + + The catalog has no checksum for %1$s + Checksum mismatch - nothing was changed + + Installing %1$s + Could not place the AppImage + Extraction failed + Install failed + + + Flatpak setup failed (exit %1$d) + Flatpak setup did not finish + Another store task is running + Set up Flatpak first + Starting + Flatpak exited with status %1$d + + Installing %1$s + Updating %1$s + Removing %1$s + + Installing %1$s (%2$d/%3$d) + Updating %1$s (%2$d/%3$d) + Removing %1$s (%2$d/%3$d) + + translations + graphics drivers + media codecs + + %1$s runtime + + %1$s runtime %2$s + + + The file is too small to be an AppImage + + The file cannot be read: %1$s + This is not an AppImage (it is not a Linux program) + This AppImage is built for neither ARM64 (aarch64) nor x86 PCs + This is a Linux program but not an AppImage + Only type 2 AppImages can be imported (this one is type %1$d) + + Not enough free space: importing needs about %1$s + + Not enough free space: updating needs about %1$s + Import failed + That AppImage is gone + Update failed + + Extracting %1$s + The AppImage could not be extracted + + The AppImage could not be extracted: %1$s + + + Stop the active session before installing Decky Loader + Decky Loader download failed + Decky Loader size did not match the release metadata + Could not download the release checksum + Release checksum is missing + Release checksum is invalid + Decky Loader checksum mismatch + Release asset is not a compatible 64-bit Linux executable + A session started during the download; stop it before installing Decky Loader + Could not mark PluginLoader executable + Could not enable Steam CEF remote debugging + Could not stage PluginLoader + Could not preserve the existing PluginLoader + Could not activate the new PluginLoader + Stop the active session before installing a plugin + Install Decky Loader before importing a plugin + Could not read the selected ZIP file + Could not create the Decky plugins directory + Extracting plugin ZIP + Plugin ZIP must contain one top-level plugin folder + Plugin ZIP is missing plugin.json, package.json, or dist/index.js + Plugin name is missing + A session started during installation; stop it before installing a plugin + Plugin installed · restart Steam to load it + Plugin ZIP installation failed + Plugin ZIP contains too many entries + Plugin ZIP contains an invalid path + Plugin ZIP contains duplicate paths + Plugin ZIP contains an unsafe path + Could not create plugin folder + Plugin ZIP expands beyond the 1 GiB limit + Could not mark bundled plugin binary executable + Invalid remote_binary entry + Invalid remote binary name + Remote binary URL must use HTTPS + + Remote binary %1$s has an invalid SHA-256 hash + Could not create plugin bin directory + + Downloading plugin binary · %1$s + + Could not download plugin binary %1$s + + Plugin binary checksum mismatch · %1$s + + Could not mark plugin binary executable · %1$s + + Could not replace plugin binary · %1$s + + Could not install plugin binary · %1$s + Plugin folder belongs to another plugin + Could not preserve the existing plugin + Could not activate the imported plugin + Could not update Decky plugin settings + Could not restore the existing plugin + + + Could not save Wireless debugging identity + + ADB command failed (%1$d) + + Android did not confirm the child-process limit was changed: %1$s + No Wireless debugging pairing is saved + Enter the current Wireless debugging IP address & Port + + + Timed out waiting for the pairing pop-up. Try again from DroidDeck. + + Could not look for the pairing pop-up (%1$d). Check that Wi-Fi is on. + + Could not look for the pairing pop-up: %1$s + Enter all 6 digits of the code + Pairing… + That code didn\'t work. Open the pairing pop-up again and enter its new code. + Paired. Connecting… + Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again. + Applying the setting… + Wireless debugging command failed + Wireless debugging setup + Asks for the Wireless debugging pairing code while Settings is open + Pair Wireless debugging + Turn on Wireless debugging, then tap “Pair device with pairing code”. + Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification. + + 6-digit pairing code + Tap Enter code and type the 6 digits shown in Settings. + Enter the Wi-Fi pairing code + Try the code again + + Enter code + Setting up Steam + Steam is ready + Tap to go back to DroidDeck. You can turn Wireless debugging off now. + Wireless debugging setup stopped + + + The download answered HTTP %1$d + The download size didn\'t match the published APK and was discarded - try again + The download didn\'t match its checksum and was discarded - try again + + The update is for Android package %1$s, not %2$s + The update metadata doesn\'t name DroidDeck\'s release signing key + Android couldn\'t read the downloaded APK + + The downloaded APK is for %1$s, not %2$s + The downloaded APK says versionCode %1$d, not the published %2$d + Android won\'t install versionCode %1$d over the installed %2$d + The downloaded APK has no signing information + The downloaded APK isn\'t signed with DroidDeck\'s release key + The update catalog answered HTTP %1$d + diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt index 49ff62126..5f753715f 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt @@ -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() { diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DeckyPluginInstallTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyPluginInstallTest.kt index 4d65572a9..5edb3451d 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DeckyPluginInstallTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyPluginInstallTest.kt @@ -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")) } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt index ed2ce0861..9b93b9938 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt @@ -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() + val percents = mutableListOf() val followerResult = AtomicReference() 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() - assertFalse(removal.run { stage, _ -> stages.add(stage) }) + val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!! + val percents = mutableListOf() + 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()) } } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt index eb63a65a2..748549560 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt @@ -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) diff --git a/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt b/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt index 5aa2dfc2f..df2b3acac 100644 --- a/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt @@ -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")) } }