l10n: move runtime, update and core manager UI text into string resources (#212)

This commit is contained in:
maxjivi05 committed 2026-10-04 22:34:18 -04:00
1 parent 1560817716
commit 59010f1def
17 files changed
+486 -220

No files matched your search

@@ -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)
}
}
@@ -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)
@@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
import android.os.StatFs
import android.system.Os
import android.util.Log
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
@@ -27,6 +29,11 @@ object AppImageManager {
val guestDir: String get() = "$GUEST_DIR/$id"
}
/** Why a file cannot be imported: [text] with its [args]. */
internal class Problem(@StringRes val text: Int, vararg val args: Any) {
fun describe(context: Context): String = context.getString(text, *args)
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
@@ -44,22 +51,22 @@ object AppImageManager {
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
internal fun problem(file: File): Problem? {
if (!file.isFile) return Problem(R.string.user_apps_file_gone)
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return Problem(R.string.appimg_too_small) }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
return Problem(R.string.appimg_unreadable, e.message.toString())
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
return Problem(R.string.appimg_not_linux)
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> Problem(R.string.appimg_wrong_arch)
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> Problem(R.string.appimg_not_appimage)
head[10].toInt() != 2 -> Problem(R.string.appimg_wrong_type, head[10].toInt())
else -> null
}
}
@@ -83,12 +90,12 @@ object AppImageManager {
fun import(
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), onProgress: (String) -> Unit,
): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
problem(file)?.let { return it.describe(context) }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_import, FileUtils.sizeToString(file.length() * 3))
}
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
try {
@@ -103,17 +110,17 @@ object AppImageManager {
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
return e.message ?: context.getString(R.string.appimg_import_failed)
}
}
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
problem(file)?.let { return it }
problem(file)?.let { return it.describe(context) }
val dir = dir(context, id)
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
if (!File(dir, "app").isDirectory) return context.getString(R.string.appimg_gone)
if (StatFs(dir.path).availableBytes < file.length() * 3) {
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
return context.getString(R.string.appimg_no_space_update, FileUtils.sizeToString(file.length() * 3))
}
return try {
extract(context, dir, file, onProgress) ?: run {
@@ -123,7 +130,7 @@ object AppImageManager {
}
} catch (e: Exception) {
Log.e(TAG, "replace $id", e)
e.message ?: "Update failed"
e.message ?: context.getString(R.string.appimg_update_failed)
}
}
@@ -135,10 +142,10 @@ object AppImageManager {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
onProgress(context.getString(R.string.user_apps_copying, file.name))
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
onProgress(context.getString(R.string.appimg_extracting, file.name))
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
val out = StringBuilder()
@@ -152,7 +159,8 @@ object AppImageManager {
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
return out.lines().lastOrNull { it.isNotBlank() }?.let { context.getString(R.string.appimg_extract_failed_detail, it) }
?: context.getString(R.string.appimg_extract_failed)
}
copyLinkedFiles(File(dir, "app"), links)
return null
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.core.ArchivePaths
@@ -24,13 +25,17 @@ object DeckyManager {
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
/** True while a plugin's remote binary downloads: what the UI branches on, not the stage's words. */
@Volatile var downloadingBinary = false
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
if (!loader.isFile || !loader.canExecute()) return null
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: context.getString(R.string.setup_installed)
}
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
@@ -102,39 +107,40 @@ object DeckyManager {
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing Decky Loader"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_loader)
val target = loader(context)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return "Decky Loader download failed" }
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return context.getString(R.string.deckymgr_checksum_mismatch) }
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
val staged = File(target.parentFile, "PluginLoader.new")
staged.delete()
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
if (!temp.renameTo(staged)) { temp.delete(); return context.getString(R.string.deckymgr_stage_failed) }
val old = File(target.parentFile, "PluginLoader.old")
old.delete()
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
if (target.exists() && !target.renameTo(old)) { staged.delete(); return context.getString(R.string.deckymgr_loader_preserve_failed) }
if (!staged.renameTo(target)) {
if (old.exists()) old.renameTo(target)
staged.delete()
return "Could not activate the new PluginLoader"
return context.getString(R.string.deckymgr_loader_activate_failed)
}
old.delete()
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
@@ -145,43 +151,44 @@ object DeckyManager {
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing a plugin"
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
if (SessionState.running) return context.getString(R.string.deckymgr_stop_session_plugin)
if (installed(context) == null) return context.getString(R.string.deckymgr_loader_required)
if (!archive.isFile || !archive.canRead()) return context.getString(R.string.deckymgr_zip_unreadable)
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
if (!plugins.exists() && !plugins.mkdirs()) return context.getString(R.string.deckymgr_plugins_dir_failed)
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
try {
staging.mkdirs()
progress("Extracting plugin ZIP", -1)
extractPluginZip(archive, staging)
progress(context.getString(R.string.deckymgr_extracting_zip), -1)
extractPluginZip(context, archive, staging)
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
if (roots.size != 1 || staging.listFiles()?.size != 1) return context.getString(R.string.deckymgr_zip_one_folder)
val plugin = roots.single()
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
return context.getString(R.string.deckymgr_zip_missing_files)
}
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
require(name.isNotBlank()) { "Plugin name is missing" }
require(name.isNotBlank()) { context.getString(R.string.deckymgr_plugin_name_missing) }
val metadata = JSONObject(File(plugin, "package.json").readText())
downloadRemoteBinaries(metadata, plugin, progress)
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
downloadRemoteBinaries(context, metadata, plugin, progress)
if (SessionState.running) return context.getString(R.string.deckymgr_session_started_plugin)
DeckyPluginInstall.activate(plugin, plugins, name,
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
progress("Plugin installed · restart Steam to load it", 100)
progress(context.getString(R.string.deckymgr_plugin_installed), 100)
return null
} catch (e: Exception) {
Log.w(TAG, "plugin ZIP import", e)
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
if (e is DeckyPluginInstall.Failure) return context.getString(e.text)
return e.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.deckymgr_plugin_install_failed)
} finally {
staging.deleteRecursively()
}
}
private fun extractPluginZip(archive: File, destination: File) {
private fun extractPluginZip(context: Context, archive: File, destination: File) {
var totalBytes = 0L
var entries = 0
val seen = HashSet<String>()
@@ -189,30 +196,30 @@ object DeckyManager {
while (true) {
val entry = zip.nextEntry ?: break
entries++
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
require(entries <= 10_000) { context.getString(R.string.deckymgr_zip_too_many) }
val relative = entry.name.replace('\\', '/')
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
require(relative.isNotBlank() && !relative.startsWith('/')) { context.getString(R.string.deckymgr_zip_invalid_path) }
require(seen.add(relative)) { context.getString(R.string.deckymgr_zip_duplicate_paths) }
val output = ArchivePaths.inside(destination, relative)
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
?: throw IllegalArgumentException(context.getString(R.string.deckymgr_zip_unsafe_path))
if (entry.isDirectory) {
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
require(output.mkdirs() || output.isDirectory) { context.getString(R.string.deckymgr_folder_failed) }
} else {
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { context.getString(R.string.deckymgr_folder_failed) }
FileOutputStream(output).use { out ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = zip.read(buffer)
if (read < 0) break
totalBytes += read
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { context.getString(R.string.deckymgr_zip_too_large) }
out.write(buffer, 0, read)
}
}
// Decky distributions place plugin executables under bin/. Java's ZIP reader
// does not expose Unix mode bits, so restore execution on those payload files.
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
throw IllegalStateException("Could not mark bundled plugin binary executable")
throw IllegalStateException(context.getString(R.string.deckymgr_bundled_not_executable))
}
}
zip.closeEntry()
@@ -220,42 +227,49 @@ object DeckyManager {
}
}
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
private fun downloadRemoteBinaries(context: Context, metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
val binaries = metadata.optJSONArray("remote_binary") ?: return
val bin = File(plugin, "bin")
for (i in 0 until binaries.length()) {
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException(context.getString(R.string.deckymgr_remote_entry_invalid))
val name = binary.optString("name")
val url = binary.optString("url")
val hash = binary.optString("sha256hash")
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { context.getString(R.string.deckymgr_remote_name_invalid) }
require(url.startsWith("https://")) { context.getString(R.string.deckymgr_remote_url_https) }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { context.getString(R.string.deckymgr_remote_hash_invalid, name) }
require(bin.mkdirs() || bin.isDirectory) { context.getString(R.string.deckymgr_bin_dir_failed) }
val temp = File(bin, ".$name.download")
progress("Downloading plugin binary · $name", -1)
if (!Downloader.downloadFile(url, temp, false) { fraction ->
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}) {
val downloading = context.getString(R.string.deckymgr_downloading_binary, name)
downloadingBinary = true
progress(downloading, -1)
val downloaded = try {
Downloader.downloadFile(url, temp, false) { fraction ->
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}
} finally {
downloadingBinary = false
}
if (!downloaded) {
temp.delete()
throw IllegalStateException("Could not download plugin binary $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_download_failed, name))
}
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
temp.delete()
throw IllegalStateException("Plugin binary checksum mismatch · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_checksum_mismatch, name))
}
if (!temp.setExecutable(true, false)) {
temp.delete()
throw IllegalStateException("Could not mark plugin binary executable · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_not_executable, name))
}
val target = File(bin, name)
if (target.exists() && !target.delete()) {
temp.delete()
throw IllegalStateException("Could not replace plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_replace_failed, name))
}
if (!temp.renameTo(target)) {
temp.delete()
throw IllegalStateException("Could not install plugin binary · $name")
throw IllegalStateException(context.getString(R.string.deckymgr_binary_install_failed, name))
}
}
}
@@ -1,11 +1,16 @@
package com.droiddeck.launcher.runtime
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
internal object DeckyPluginInstall {
/** A failure the user is told about: [text] is its string resource. */
class Failure(@StringRes val text: Int) : IllegalStateException()
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
val existing = plugins.listFiles().orEmpty().filter { folder ->
@@ -14,7 +19,7 @@ internal object DeckyPluginInstall {
}.getOrDefault(false)
}
val target = File(plugins, staged.name)
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
if (target.exists() && target !in existing) throw Failure(R.string.deckymgr_folder_taken)
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
@@ -34,15 +39,15 @@ internal object DeckyPluginInstall {
pending.writeText(settings.toString(4))
for (old in existing) {
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
if (!old.renameTo(backup)) throw Failure(R.string.deckymgr_preserve_failed)
backups.add(old to backup)
}
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
if (!staged.renameTo(target)) throw Failure(R.string.deckymgr_activate_failed)
activated = true
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
if (!pending.renameTo(settingsFile)) throw Failure(R.string.deckymgr_settings_failed)
} catch (error: Exception) {
if (activated) target.deleteRecursively()
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
for ((old, backup) in backups.asReversed()) if (!backup.renameTo(old)) throw Failure(R.string.deckymgr_restore_failed)
throw error
} finally {
pending.delete()
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
@@ -81,20 +82,21 @@ object DesktopCatalog {
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
listener?.onProgress("Verifying", -1)
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -102,20 +104,20 @@ object DesktopCatalog {
// Some projects zip the AppImage (melonDS); the one file inside is what we want.
val placed = if (entry.url.endsWith(".zip", ignoreCase = true)) unzipAppImage(download, target)
else download.renameTo(target)
if (!placed) return "Could not place the AppImage"
if (!placed) return context.getString(R.string.deskpkg_place_failed)
target.setExecutable(true, false)
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> if (!LinuxRuntimeInstaller.extract(download, root, listener)) return "Extraction failed"
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
FileUtils.writeString(marker(context, entry.id), entry.version)
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: "Install failed"
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
@@ -102,7 +103,7 @@ object FlatpakManager {
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
@@ -114,11 +115,11 @@ object FlatpakManager {
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
status != 0 -> context.getString(R.string.flatpakmgr_setup_failed, status)
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
else -> null
}
} ?: "Another store task is running"
} ?: context.getString(R.string.flatpakmgr_busy)
}
/**
@@ -126,51 +127,61 @@ object FlatpakManager {
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
return exclusive("$verb:${id ?: "all"}") {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
var step = context.getString(R.string.flatpakmgr_starting)
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"op" -> { step = stage(context, o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
} ?: context.getString(R.string.flatpakmgr_busy)
}
private fun stage(o: JSONObject): String {
private fun stage(context: Context, o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
if (ref.isEmpty()) return o.optString("status").ifEmpty { context.getString(R.string.setup_check_busy) }
val name = refLabel(ref) { id, args -> context.getString(id, *args) }
val n = o.optInt("n")
val kind = o.optString("kind")
return if (n > 1) context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing_step
"update" -> R.string.flatpakmgr_updating_step
else -> R.string.flatpakmgr_installing_step
}, name, o.optInt("i"), n)
else context.getString(when (kind) {
"uninstall" -> R.string.flatpakmgr_removing
"update" -> R.string.flatpakmgr_updating
else -> R.string.flatpakmgr_installing
}, name)
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
/**
* A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". [text]
* resolves a string resource with its arguments.
*/
internal fun refLabel(ref: String, text: (Int, Array<out Any>) -> String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
val branch = parts.getOrNull(3)
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Locale") -> text(R.string.flatpakmgr_ref_translations, emptyArray())
".GL." in id || ".GL32." in id -> text(R.string.flatpakmgr_ref_graphics_drivers, emptyArray())
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> text(R.string.flatpakmgr_ref_media_codecs, emptyArray())
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
}.let { if (branch != null) text(R.string.flatpakmgr_ref_runtime_branch, arrayOf(it, branch)) else text(R.string.flatpakmgr_ref_runtime, arrayOf(it)) }
else -> id.substringAfterLast('.')
}
}
@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
@@ -44,9 +45,17 @@ public final class LinuxRuntimeInstaller {
private static final String VERSION_FILE = ".version";
/** What a stage is doing, for code that branches on it rather than on its (translated) words. */
public enum Step { DOWNLOADING, VERIFYING, OTHER }
public interface ProgressListener {
/** {@code percent} is -1 while the size is unknown. */
void onProgress(String stage, int percent);
/** The same with its {@link Step}; a listener that branches on the step overrides this one. */
default void onProgress(Step step, String stage, int percent) {
onProgress(stage, percent);
}
}
public static final class Release {
@@ -71,11 +80,12 @@ public final class LinuxRuntimeInstaller {
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile String stage;
volatile Step step = Step.OTHER;
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
Job(boolean removal) { this.removal = removal; }
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
}
private static final Object JOB_LOCK = new Object();
@@ -131,16 +141,23 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) {
if (running != null && running.removal) return false;
owner = running == null;
if (owner) { running = new Job(false); removalError = null; }
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
job.ok = installOnce(context.getApplicationContext(), release, new ProgressListener() {
@Override public void onProgress(String stage, int percent) {
onProgress(Step.OTHER, stage, percent);
}
@Override public void onProgress(Step step, String stage, int percent) {
job.step = step;
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(step, stage, percent);
}
});
return job.ok;
} finally {
@@ -192,7 +209,7 @@ public final class LinuxRuntimeInstaller {
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
if (listener != null) listener.onProgress(job.step, job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
@@ -209,13 +226,14 @@ public final class LinuxRuntimeInstaller {
// A failed/interrupted explicit removal must finish before a fresh installation.
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (listener != null) listener.onProgress("Downloading", 0);
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
if (listener != null) {
listener.onProgress("Downloading",
listener.onProgress(Step.DOWNLOADING, downloading,
fraction < 0 ? -1 : Math.round(fraction * 100f));
}
});
@@ -224,7 +242,7 @@ public final class LinuxRuntimeInstaller {
return false;
}
if (listener != null) listener.onProgress("Verifying", -1);
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
@@ -239,8 +257,8 @@ public final class LinuxRuntimeInstaller {
recoverInterruptedSwap(root, staging, old);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
if (!extract(archive, staging, listener)) {
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -307,30 +325,35 @@ public final class LinuxRuntimeInstaller {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
return beginUninstall(LinuxRuntime.rootDir(context));
return beginUninstall(context.getApplicationContext(), LinuxRuntime.rootDir(context));
}
static Removal beginUninstall(File root) {
/** {@code app} words the progress; unit tests pass null and get none. */
static Removal beginUninstall(Context app, File root) {
synchronized (JOB_LOCK) {
if (running != null) return null;
removalError = null;
running = new Job(true);
running.stage = "Removing Linux runtime";
return new Removal(root, running);
running = new Job(true, text(app, R.string.rtinst_removing));
return new Removal(app, root, running);
}
}
private static String text(Context app, int id) {
return app == null ? "" : app.getString(id);
}
public static final class Removal {
private final Context app;
private final File root;
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Removal(File root, Job job) { this.root = root; this.job = job; }
private Removal(Context app, File root, Job job) { this.app = app; this.root = root; this.job = job; }
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
report("Removing Linux runtime", -1);
report(text(app, R.string.rtinst_removing), -1);
final long[] removed = {0};
final long[] lastShown = {0};
java.util.function.LongConsumer progress = ignored -> {
@@ -338,7 +361,8 @@ public final class LinuxRuntimeInstaller {
long now = System.nanoTime();
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
lastShown[0] = now;
report("Removing Linux runtime · " + removed[0] + " entries", -1);
report(app == null ? "" : app.getResources().getQuantityString(
R.plurals.rtinst_removing_entries, (int) Math.min(removed[0], Integer.MAX_VALUE), removed[0]), -1);
}
};
File pending = removalDirectory(root);
@@ -355,10 +379,10 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
job.ok = true;
report("Linux runtime removed", 100);
report(text(app, R.string.rtinst_removed), 100);
return true;
} catch (IOException e) {
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
removalError = text(app, R.string.rtinst_removal_failed);
Log.w(TAG, "runtime removal incomplete", e);
report(removalError, -1);
return false;
@@ -380,8 +404,9 @@ public final class LinuxRuntimeInstaller {
* empty file instead of its link target is a rootfs that boots to nothing. Symlinks, hard
* links and the executable bit are all carried over here.
*/
public static boolean extract(File archive, File destination, ProgressListener listener) {
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -432,7 +457,7 @@ public final class LinuxRuntimeInstaller {
continue; // device nodes and fifos: the runtime binds the real ones
}
if (++entries % 2000 == 0 && listener != null) {
listener.onProgress("Extracting", -1);
listener.onProgress(extracting, -1);
}
}
return true;
@@ -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)
@@ -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()
@@ -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))
}
}
}
+186
View File
@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Linux runtime install / removal (LinuxRuntimeInstaller, RuntimeInstallService) -->
<string name="rtinst_removing">Removing Linux runtime</string>
<plurals name="rtinst_removing_entries">
<item quantity="one">Removing Linux runtime · %1$d entry</item>
<item quantity="other">Removing Linux runtime · %1$d entries</item>
</plurals>
<string name="rtinst_removed">Linux runtime removed</string>
<string name="rtinst_removal_failed">Could not finish removing the Linux runtime. Retry removal in Setup.</string>
<string name="rtinst_removing_leftovers">Removing runtime leftovers</string>
<string name="rtinst_downloading">Downloading</string>
<string name="rtinst_verifying">Verifying</string>
<string name="rtinst_extracting">Extracting</string>
<string name="rtinst_channel_description">Shows while the Linux runtime is installed or removed</string>
<string name="rtinst_title_installing">Installing the Linux runtime</string>
<string name="rtinst_title_removing">Removing the Linux runtime</string>
<!-- Hosted desktop and emulator packages (DesktopCatalog) -->
<string name="deskpkg_runtime_missing">The Linux runtime is not installed</string>
<!-- %1$s: package name -->
<string name="deskpkg_no_checksum">The catalog has no checksum for %1$s</string>
<string name="deskpkg_checksum_mismatch">Checksum mismatch - nothing was changed</string>
<!-- %1$s: package name -->
<string name="deskpkg_installing">Installing %1$s</string>
<string name="deskpkg_place_failed">Could not place the AppImage</string>
<string name="deskpkg_extract_failed">Extraction failed</string>
<string name="deskpkg_install_failed">Install failed</string>
<!-- Flatpak store operations (FlatpakManager) -->
<string name="flatpakmgr_setup_failed">Flatpak setup failed (exit %1$d)</string>
<string name="flatpakmgr_setup_unfinished">Flatpak setup did not finish</string>
<string name="flatpakmgr_busy">Another store task is running</string>
<string name="flatpakmgr_not_ready">Set up Flatpak first</string>
<string name="flatpakmgr_starting">Starting</string>
<string name="flatpakmgr_exit_status">Flatpak exited with status %1$d</string>
<!-- %1$s: an app or runtime name, e.g. "SuperTux" or "GNOME runtime 50" -->
<string name="flatpakmgr_installing">Installing %1$s</string>
<string name="flatpakmgr_updating">Updating %1$s</string>
<string name="flatpakmgr_removing">Removing %1$s</string>
<!-- %1$s: an app or runtime name; %2$d of %3$d: this step and the number of steps -->
<string name="flatpakmgr_installing_step">Installing %1$s (%2$d/%3$d)</string>
<string name="flatpakmgr_updating_step">Updating %1$s (%2$d/%3$d)</string>
<string name="flatpakmgr_removing_step">Removing %1$s (%2$d/%3$d)</string>
<!-- Names for Flatpak runtime parts, shown inside "Installing %1$s" -->
<string name="flatpakmgr_ref_translations">translations</string>
<string name="flatpakmgr_ref_graphics_drivers">graphics drivers</string>
<string name="flatpakmgr_ref_media_codecs">media codecs</string>
<!-- %1$s: runtime vendor, e.g. GNOME, KDE, Freedesktop -->
<string name="flatpakmgr_ref_runtime">%1$s runtime</string>
<!-- %1$s: runtime vendor, e.g. GNOME; %2$s: its version, e.g. 50 -->
<string name="flatpakmgr_ref_runtime_branch">%1$s runtime %2$s</string>
<!-- Imported AppImages (AppImageManager) -->
<string name="appimg_too_small">The file is too small to be an AppImage</string>
<!-- %1$s: the system's reason -->
<string name="appimg_unreadable">The file cannot be read: %1$s</string>
<string name="appimg_not_linux">This is not an AppImage (it is not a Linux program)</string>
<string name="appimg_wrong_arch">This AppImage is built for neither ARM64 (aarch64) nor x86 PCs</string>
<string name="appimg_not_appimage">This is a Linux program but not an AppImage</string>
<string name="appimg_wrong_type">Only type 2 AppImages can be imported (this one is type %1$d)</string>
<!-- %1$s: a size such as "3.2 GB" -->
<string name="appimg_no_space_import">Not enough free space: importing needs about %1$s</string>
<!-- %1$s: a size such as "3.2 GB" -->
<string name="appimg_no_space_update">Not enough free space: updating needs about %1$s</string>
<string name="appimg_import_failed">Import failed</string>
<string name="appimg_gone">That AppImage is gone</string>
<string name="appimg_update_failed">Update failed</string>
<!-- %1$s: file name -->
<string name="appimg_extracting">Extracting %1$s</string>
<string name="appimg_extract_failed">The AppImage could not be extracted</string>
<!-- %1$s: the last line the extractor printed -->
<string name="appimg_extract_failed_detail">The AppImage could not be extracted: %1$s</string>
<!-- Decky Loader and plugin installs (DeckyManager) -->
<string name="deckymgr_stop_session_loader">Stop the active session before installing Decky Loader</string>
<string name="deckymgr_download_failed">Decky Loader download failed</string>
<string name="deckymgr_size_mismatch">Decky Loader size did not match the release metadata</string>
<string name="deckymgr_checksum_download_failed">Could not download the release checksum</string>
<string name="deckymgr_checksum_missing">Release checksum is missing</string>
<string name="deckymgr_checksum_invalid">Release checksum is invalid</string>
<string name="deckymgr_checksum_mismatch">Decky Loader checksum mismatch</string>
<string name="deckymgr_not_elf">Release asset is not a compatible 64-bit Linux executable</string>
<string name="deckymgr_session_started_loader">A session started during the download; stop it before installing Decky Loader</string>
<string name="deckymgr_loader_not_executable">Could not mark PluginLoader executable</string>
<string name="deckymgr_cef_failed">Could not enable Steam CEF remote debugging</string>
<string name="deckymgr_stage_failed">Could not stage PluginLoader</string>
<string name="deckymgr_loader_preserve_failed">Could not preserve the existing PluginLoader</string>
<string name="deckymgr_loader_activate_failed">Could not activate the new PluginLoader</string>
<string name="deckymgr_stop_session_plugin">Stop the active session before installing a plugin</string>
<string name="deckymgr_loader_required">Install Decky Loader before importing a plugin</string>
<string name="deckymgr_zip_unreadable">Could not read the selected ZIP file</string>
<string name="deckymgr_plugins_dir_failed">Could not create the Decky plugins directory</string>
<string name="deckymgr_extracting_zip">Extracting plugin ZIP</string>
<string name="deckymgr_zip_one_folder">Plugin ZIP must contain one top-level plugin folder</string>
<string name="deckymgr_zip_missing_files">Plugin ZIP is missing plugin.json, package.json, or dist/index.js</string>
<string name="deckymgr_plugin_name_missing">Plugin name is missing</string>
<string name="deckymgr_session_started_plugin">A session started during installation; stop it before installing a plugin</string>
<string name="deckymgr_plugin_installed">Plugin installed · restart Steam to load it</string>
<string name="deckymgr_plugin_install_failed">Plugin ZIP installation failed</string>
<string name="deckymgr_zip_too_many">Plugin ZIP contains too many entries</string>
<string name="deckymgr_zip_invalid_path">Plugin ZIP contains an invalid path</string>
<string name="deckymgr_zip_duplicate_paths">Plugin ZIP contains duplicate paths</string>
<string name="deckymgr_zip_unsafe_path">Plugin ZIP contains an unsafe path</string>
<string name="deckymgr_folder_failed">Could not create plugin folder</string>
<string name="deckymgr_zip_too_large">Plugin ZIP expands beyond the 1 GiB limit</string>
<string name="deckymgr_bundled_not_executable">Could not mark bundled plugin binary executable</string>
<string name="deckymgr_remote_entry_invalid">Invalid remote_binary entry</string>
<string name="deckymgr_remote_name_invalid">Invalid remote binary name</string>
<string name="deckymgr_remote_url_https">Remote binary URL must use HTTPS</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_remote_hash_invalid">Remote binary %1$s has an invalid SHA-256 hash</string>
<string name="deckymgr_bin_dir_failed">Could not create plugin bin directory</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_downloading_binary">Downloading plugin binary · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_download_failed">Could not download plugin binary %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_checksum_mismatch">Plugin binary checksum mismatch · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_not_executable">Could not mark plugin binary executable · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_replace_failed">Could not replace plugin binary · %1$s</string>
<!-- %1$s: binary file name -->
<string name="deckymgr_binary_install_failed">Could not install plugin binary · %1$s</string>
<string name="deckymgr_folder_taken">Plugin folder belongs to another plugin</string>
<string name="deckymgr_preserve_failed">Could not preserve the existing plugin</string>
<string name="deckymgr_activate_failed">Could not activate the imported plugin</string>
<string name="deckymgr_settings_failed">Could not update Decky plugin settings</string>
<string name="deckymgr_restore_failed">Could not restore the existing plugin</string>
<!-- Child-process limit change over Wireless debugging (WirelessAdbFix) -->
<string name="adbfix_save_failed">Could not save Wireless debugging identity</string>
<!-- %1$d: the command's exit status -->
<string name="adbfix_command_failed">ADB command failed (%1$d)</string>
<!-- %1$s: what Android answered -->
<string name="adbfix_not_confirmed">Android did not confirm the child-process limit was changed: %1$s</string>
<string name="adbfix_no_pairing">No Wireless debugging pairing is saved</string>
<string name="adbfix_enter_address">Enter the current Wireless debugging IP address &amp; Port</string>
<!-- Wireless debugging pairing notification (WirelessAdbPairingService) -->
<string name="adbpair_timed_out">Timed out waiting for the pairing pop-up. Try again from DroidDeck.</string>
<!-- %1$d: Android's error code -->
<string name="adbpair_discovery_failed_code">Could not look for the pairing pop-up (%1$d). Check that Wi-Fi is on.</string>
<!-- %1$s: the system's reason -->
<string name="adbpair_discovery_failed">Could not look for the pairing pop-up: %1$s</string>
<string name="adbpair_six_digits">Enter all 6 digits of the code</string>
<string name="adbpair_pairing">Pairing…</string>
<string name="adbpair_code_rejected">That code didn\'t work. Open the pairing pop-up again and enter its new code.</string>
<string name="adbpair_connecting">Paired. Connecting…</string>
<string name="adbpair_port_not_found">Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.</string>
<string name="adbpair_applying">Applying the setting…</string>
<string name="adbpair_command_failed">Wireless debugging command failed</string>
<string name="adbpair_channel_name">Wireless debugging setup</string>
<string name="adbpair_channel_description">Asks for the Wireless debugging pairing code while Settings is open</string>
<string name="adbpair_title_pair">Pair Wireless debugging</string>
<string name="adbpair_waiting_text">Turn on Wireless debugging, then tap “Pair device with pairing code”.</string>
<string name="adbpair_waiting_long">Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification.</string>
<!-- Label of the notification's reply field -->
<string name="adbpair_code_label">6-digit pairing code</string>
<string name="adbpair_code_hint">Tap Enter code and type the 6 digits shown in Settings.</string>
<string name="adbpair_title_code">Enter the Wi-Fi pairing code</string>
<string name="adbpair_title_retry">Try the code again</string>
<!-- Notification action button -->
<string name="adbpair_enter_code">Enter code</string>
<string name="adbpair_title_working">Setting up Steam</string>
<string name="adbpair_title_done">Steam is ready</string>
<string name="adbpair_done_text">Tap to go back to DroidDeck. You can turn Wireless debugging off now.</string>
<string name="adbpair_title_failed">Wireless debugging setup stopped</string>
<!-- DroidDeck's own updates (SelfInstaller, AppUpdates) -->
<string name="selfinst_http">The download answered HTTP %1$d</string>
<string name="selfinst_size_mismatch">The download size didn\'t match the published APK and was discarded - try again</string>
<string name="selfinst_checksum_mismatch">The download didn\'t match its checksum and was discarded - try again</string>
<!-- %1$s: the update's Android package name; %2$s: this app's -->
<string name="selfinst_wrong_package">The update is for Android package %1$s, not %2$s</string>
<string name="selfinst_metadata_signer">The update metadata doesn\'t name DroidDeck\'s release signing key</string>
<string name="selfinst_unreadable">Android couldn\'t read the downloaded APK</string>
<!-- %1$s: the APK's Android package name; %2$s: this app's -->
<string name="selfinst_apk_wrong_package">The downloaded APK is for %1$s, not %2$s</string>
<string name="selfinst_version_mismatch">The downloaded APK says versionCode %1$d, not the published %2$d</string>
<string name="selfinst_downgrade">Android won\'t install versionCode %1$d over the installed %2$d</string>
<string name="selfinst_no_signing">The downloaded APK has no signing information</string>
<string name="selfinst_wrong_signer">The downloaded APK isn\'t signed with DroidDeck\'s release key</string>
<string name="appupd_http">The update catalog answered HTTP %1$d</string>
</resources>
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.R
import java.io.File
import java.nio.file.Files
import org.junit.After
@@ -30,15 +31,15 @@ class AppImageManagerTest {
@Test fun acceptsAnI386ImageForFex() = assertNull(AppImageManager.problem(image(3)))
@Test fun refusesOtherProcessors() = assertTrue(AppImageManager.problem(image(40))!!.contains("neither"))
@Test fun refusesOtherProcessors() = assertTrue(AppImageManager.problem(image(40))!!.text == R.string.appimg_wrong_arch)
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2"))
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.text == R.string.appimg_wrong_type)
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage"))
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.text == R.string.appimg_not_appimage)
@Test fun refusesSomethingElse() {
val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") }
assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage"))
assertTrue(AppImageManager.problem(f)!!.text == R.string.appimg_not_linux)
}
@Test fun idsAreTidyAndUnique() {
@@ -53,7 +53,7 @@ class DeckyPluginInstallTest {
try {
DeckyPluginInstall.activate(plugin(temporary.newFolder(), "Unifideck", "Unifideck"), plugins, "Unifideck", settings, ".test")
fail("Expected conflicting identity to fail")
} catch (_: IllegalArgumentException) {}
} catch (_: DeckyPluginInstall.Failure) {}
assertEquals("{}", settings.readText())
assertEquals("Other", JSONObject(File(plugins, "Unifideck/plugin.json").readText()).getString("name"))
}
@@ -77,12 +77,12 @@ class RuntimeRemovalTest {
@Test fun reservationBlocksPlayAndInstallBeforeTheWorkerStartsAndFollowersSeeProgress() {
val root = runtime()
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
assertTrue(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.isRemoving())
assertFalse(LinuxRuntimeInstaller.isInstalling())
assertFalse(LinuxRuntime.isInstalled(null))
assertNull(LinuxRuntimeInstaller.beginUninstall(root))
assertNull(LinuxRuntimeInstaller.beginUninstall(null, root))
assertFalse(LinuxRuntimeInstaller.install(null, null, null))
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
@@ -94,10 +94,10 @@ class RuntimeRemovalTest {
try {
assertTrue(entered.await(5, TimeUnit.SECONDS))
val following = CountDownLatch(1)
val stages = mutableListOf<String>()
val percents = mutableListOf<Int>()
val followerResult = AtomicReference<Boolean>()
val follower = Thread {
followerResult.set(LinuxRuntimeInstaller.attach { stage, _ -> stages.add(stage); following.countDown() })
followerResult.set(LinuxRuntimeInstaller.attach { _, percent -> percents.add(percent); following.countDown() })
}
follower.start()
assertTrue(following.await(5, TimeUnit.SECONDS))
@@ -106,7 +106,8 @@ class RuntimeRemovalTest {
follower.join(5000)
assertEquals(true, result.get())
assertEquals(true, followerResult.get())
assertEquals("Linux runtime removed", stages.last())
// The last stage is "Linux runtime removed", the only one reported at 100.
assertEquals(100, percents.last())
assertFalse(root.exists())
assertFalse(File(dir, "linuxfs.removing").exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
@@ -120,7 +121,7 @@ class RuntimeRemovalTest {
val tree = File(dir, name).apply { mkdirs() }
File(tree, "game").writeText("staged")
}
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!
assertTrue(removal.run(null))
assertTrue(dir.listFiles()!!.isEmpty())
}
@@ -128,9 +129,11 @@ class RuntimeRemovalTest {
@Test fun aPartialRemovalStaysQuarantinedAndRetryable() {
val root = runtime()
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
assertFalse(removal.run { stage, _ ->
if (stage.contains("entries"))
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
// The first stage names the removal, the second counts its first entry.
var stages = 0
assertFalse(removal.run { _, _ ->
if (++stages == 2)
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
})
assertFalse(root.exists())
@@ -138,7 +141,7 @@ class RuntimeRemovalTest {
assertFalse(LinuxRuntimeInstaller.isBusy())
assertNotNull(LinuxRuntimeInstaller.removalError())
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertFalse(File(dir, "linuxfs.removing").exists())
}
@@ -146,15 +149,15 @@ class RuntimeRemovalTest {
val root = runtime()
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
val stages = mutableListOf<String>()
assertFalse(removal.run { stage, _ -> stages.add(stage) })
val removal = LinuxRuntimeInstaller.beginUninstall(null, root)!!
val percents = mutableListOf<Int>()
assertFalse(removal.run { _, percent -> percents.add(percent) })
assertTrue(root.exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.removalError()!!.contains("Retry removal"))
assertFalse(stages.contains("Linux runtime removed"))
assertNotNull(LinuxRuntimeInstaller.removalError())
assertFalse(percents.contains(100))
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertNull(LinuxRuntimeInstaller.removalError())
}
}
@@ -31,7 +31,7 @@ class RuntimeRemovalUiTest {
}
@Test fun directSessionLaunchDuringRemovalFinishesWithoutStartingTheGuest() {
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
val removal = LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!
try {
val activity = Robolectric.buildActivity(SessionActivity::class.java).create()
assertTrue(activity.get().isFinishing)
@@ -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"))
}
}