mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
l10n: move runtime, update and core manager UI text into string resources (#212)
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user