diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt index 3a52a9c..ac215d7 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt @@ -33,6 +33,9 @@ object GameStorage { fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY) + /** Mods imported on the Patches page, one folder each, as WheelWizard keeps them on a computer. */ + const val MODS_DIRECTORY = "Mods" + /** The pack's own Code.pul, which is what a modded game and a mod translation both need. */ fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul") @@ -40,6 +43,11 @@ object GameStorage { fun modContentReady(context: Context, profile: GameProfile): Boolean = !profile.modPack || modCodePul(context).isFile + fun modsDirectory(context: Context): File = File(gameRoot(context), MODS_DIRECTORY) + + /** The pack's Patches folder, which its Riivolution XML maps onto the disc; refilled from Mods at each start. */ + fun patchesDirectory(context: Context): File = File(modDirectory(context), "Patches") + fun configFile(context: Context): File = File(gameRoot(context), "Config.toml") fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs") diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index b12b2a5..2db9d9d 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -28,7 +28,8 @@ import org.wiicompiled.quest.R /** * The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR), - * with a Home page that sets up and starts the game and a Settings page that edits Config.toml. + * with a Home page that sets up and starts the game, a Patches page that imports mods for Retro + * Rewind, and a Settings page that edits Config.toml. * * The APK carries no game code. Playing needs two things the player owns: the game files (DATA, * extracted from their disc image here or on a PC) and the game itself (libmain.so, built from @@ -41,14 +42,16 @@ import org.wiicompiled.quest.R */ class LauncherActivity : Activity() { - private enum class Page { Home, Settings } + private enum class Page { Home, Patches, Settings } /** What Home's main and secondary buttons do. */ private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private lateinit var navHome: View + private lateinit var navPatches: View private lateinit var navSettings: View private lateinit var homePage: View + private lateinit var patchesView: View private lateinit var settingsView: View private lateinit var trails: WheelTrailsView private lateinit var playButton: View @@ -64,6 +67,7 @@ class LauncherActivity : Activity() { private lateinit var homeTitle: TextView private lateinit var gameToggle: LinearLayout private lateinit var settings: SettingsPage + private lateinit var patches: PatchesPage /** The games this APK carries a kit for, and the one the player picked. */ private val profiles: List by lazy { GameProfile.available(this) } @@ -71,6 +75,8 @@ class LauncherActivity : Activity() { private var page = Page.Home private var launching = false + /** Play is copying the enabled mods into Retro Rewind's Patches folder before starting it. */ + private var installingPatches = false private var trailsAway = true private var setupKind: Class<*>? = null private var mainAction = Action.Play @@ -95,8 +101,10 @@ class LauncherActivity : Activity() { } navHome = findViewById(R.id.nav_home) + navPatches = findViewById(R.id.nav_patches) navSettings = findViewById(R.id.nav_settings) homePage = findViewById(R.id.page_home) + patchesView = findViewById(R.id.page_patches) settingsView = findViewById(R.id.page_settings) trails = findViewById(R.id.home_trails) playButton = findViewById(R.id.home_play) @@ -129,11 +137,15 @@ class LauncherActivity : Activity() { downloadModPack = ::downloadModPack, resetInstallation = ::resetInstallation, ) + patches = PatchesPage(this, patchesView) { + openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker) + } savedInstanceState?.getString(KEY_TAB)?.let { name -> SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) } navHome.setOnClickListener { showPage(Page.Home) } + navPatches.setOnClickListener { showPage(Page.Patches) } navSettings.setOnClickListener { showPage(Page.Settings) } playButton.setOnClickListener { perform(mainAction) } secondary.setOnClickListener { secondaryAction?.let(::perform) } @@ -182,8 +194,16 @@ class LauncherActivity : Activity() { @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - val uri = data?.data ?: return - if (resultCode != RESULT_OK) return + if (resultCode != RESULT_OK || data == null) return + if (requestCode == REQUEST_PATCH_FILES) { + // Several files arrive as clip data, a single one as the data URI. + val clip = data.clipData + val uris = if (clip != null) (0 until clip.itemCount).map { clip.getItemAt(it).uri } else listOfNotNull(data.data) + showPage(Page.Patches) + patches.importPicked(uris) + return + } + val uri = data.data ?: return val task = when (requestCode) { REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage @@ -197,8 +217,10 @@ class LauncherActivity : Activity() { private fun showPage(target: Page) { page = target navHome.isSelected = target == Page.Home + navPatches.isSelected = target == Page.Patches navSettings.isSelected = target == Page.Settings homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE + patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE if (target == Page.Home) { trailsAway = true @@ -209,6 +231,7 @@ class LauncherActivity : Activity() { private fun refresh() { when (page) { Page.Home -> refreshHome() + Page.Patches -> patches.refresh() Page.Settings -> settings.refresh() } } @@ -278,10 +301,14 @@ class LauncherActivity : Activity() { setup is GameSetup.State.Finishing -> getString(R.string.home_finishing) else -> getString(label(mainAction)) } + if (installingPatches) { + playButton.isEnabled = false + playText.setText(R.string.home_installing_patches) + } secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE secondaryAction?.let { secondary.setText(label(it)) } - progress.visibility = if (settingUp) View.VISIBLE else View.GONE + progress.visibility = if (settingUp || installingPatches) View.VISIBLE else View.GONE progress.isIndeterminate = setup !is GameSetup.State.Working if (setup is GameSetup.State.Working && setup.total > 0) { progress.progress = (setup.done * progress.max / setup.total).toInt() @@ -549,18 +576,19 @@ class LauncherActivity : Activity() { .show() } - private fun openPicker(requestCode: Int) { - // Disc images and game files have no reliable MIME type, so every file is offered - // and the task checks the name and contents. + private fun openPicker(requestCode: Int, multiple: Boolean = false, noPicker: Int = R.string.home_no_picker) { + // Disc images, game files and mod files have no reliable MIME type, so every file is + // offered and the task checks the name and contents. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) try { @Suppress("DEPRECATION") startActivityForResult(intent, requestCode) } catch (e: ActivityNotFoundException) { Log.w(TAG, "No document picker", e) - Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show() + Toast.makeText(this, noPicker, Toast.LENGTH_LONG).show() } } @@ -585,6 +613,66 @@ class LauncherActivity : Activity() { } private fun play() { + if (launching) { + return + } + // Resume only brings the running game back, and its files must not change under it. + if (profile.modPack && !isGameRunning()) { + preparePatches() + return + } + startGame() + } + + /** + * Retro Rewind reads its pack's Patches folder, so the enabled mods are copied into it before + * every start, as the PC launcher does (ModsLaunchService.PrepareModsForLaunch). With none + * enabled, a folder that still holds files is only cleared if the player says so. + */ + private fun preparePatches() { + val mods = ModLibrary.load(GameStorage.modsDirectory(this)) + when { + ModLibrary.shouldAskToClear(mods, GameStorage.patchesDirectory(this)) -> + AlertDialog.Builder(this) + .setTitle(R.string.patches_clear_title) + .setMessage(R.string.patches_clear_message) + .setPositiveButton(R.string.patches_delete) { _, _ -> installPatches(mods, clear = true) } + .setNegativeButton(R.string.patches_keep) { _, _ -> startGame() } + .show() + mods.any { it.enabled } -> installPatches(mods, clear = false) + else -> startGame() + } + } + + private fun installPatches(mods: List, clear: Boolean) { + if (launching) return + launching = true + installingPatches = true + refreshHome() + val modsDir = GameStorage.modsDirectory(this) + val patchesDir = GameStorage.patchesDirectory(this) + ModLibrary.background({ ModLibrary.prepareForLaunch(modsDir, patchesDir, mods, clear) }) { result -> + installingPatches = false + launching = false + if (isDestroyed) return@background + val error = result.getOrElse { it.message ?: it.toString() } + if (error != null) { + Log.w(TAG, "Mods could not be installed: $error") + refreshHome() + AlertDialog.Builder(this) + .setTitle(R.string.home_patches_failed) + .setMessage(error) + .setPositiveButton(android.R.string.ok, null) + .show() + return@background + } + Log.i(TAG, "Patches folder ready: ${mods.count { it.enabled }} of ${mods.size} mods enabled") + refreshHome() + startGame() + } + } + + private fun startGame() { if (launching) { return } @@ -630,6 +718,7 @@ class LauncherActivity : Activity() { const val PROCESS_EXIT_GRACE_MS = 1000L const val REQUEST_DISC_IMAGE = 1 const val REQUEST_GAME_PACKAGE = 2 + const val REQUEST_PATCH_FILES = 3 const val PREFERENCES = "launcher" const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME" diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt new file mode 100644 index 0000000..fe5abcf --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt @@ -0,0 +1,341 @@ +package org.wiicompiled.quest.launcher + +import android.os.Handler +import android.os.Looper +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.util.Locale +import java.util.concurrent.Executors +import java.util.zip.ZipFile + +/** + * The mods imported on the Patches page, kept the way WheelWizard VR keeps them on a computer + * (Features/Mods): one folder per mod under Mods/, holding the mod's files and a `.ini` with + * its state, so a Mods folder copied from one launcher reads the same in the other. + * + * Mods only change Retro Rewind. Before it starts, [plan] and [sync] flatten the enabled mods into + * the pack's Patches folder, which the pack's Riivolution XML maps onto the disc (/patches, /sound), + * as the PC launcher's ModsLaunchService does before every Retro Rewind launch. A file two mods + * both carry comes from the one higher in the list, which is the one with the lower priority. + */ +object ModLibrary { + + /** One imported mod, as its `.ini` describes it. Author and ModID only come from the PC's mod browser. */ + data class Mod( + val title: String, + val enabled: Boolean, + val priority: Int, + val author: String = NO_ID, + val modId: Int = -1, + ) + + /** One file picked for an import: its display name and how to read it. */ + class Source(val name: String, val open: () -> InputStream) + + enum class NameProblem { Empty, Exists, IllegalCharacters } + + private const val SECTION = "Mod" + private const val NO_ID = "-1" + + /** ModManager._illegalChars plus Windows' invalid file name characters, so a name travels to the PC. */ + private val ILLEGAL_NAME_CHARACTERS = ".~/\\<>:\"|?*".toSet() + + /** Archives the PC unpacks with SharpCompress; without it, only .zip can be opened here. */ + private val UNSUPPORTED_ARCHIVES = listOf(".7z", ".rar") + + // Metadata + + /** Every mod under [modsDir] (`/.ini`), in list order: by priority, top first. */ + fun load(modsDir: File): List = + modsDir.listFiles { file -> file.isDirectory } + .orEmpty() + .mapNotNull { folder -> + val ini = File(folder, "${folder.name}.ini") + if (!ini.isFile) return@mapNotNull null + runCatching { parseIni(ini.readText()) }.getOrNull() + } + .sortedWith(compareBy { it.priority }.thenBy { it.title.lowercase(Locale.ROOT) }) + + /** + * Reads what Mod.LoadFromIniAsync reads, with its defaults: enabled unless it says otherwise, + * priority 0 and ModID -1 when absent. Null without a name, which the PC skips as well. + */ + fun parseIni(text: String): Mod? { + val values = mutableMapOf() + var section = "" + for (raw in text.removePrefix("").lineSequence()) { + val line = raw.trim() + when { + line.isEmpty() || line.startsWith(";") || line.startsWith("#") -> Unit + line.startsWith("[") && line.endsWith("]") -> section = line.substring(1, line.length - 1).trim() + section.equals(SECTION, ignoreCase = true) && '=' in line -> + values[line.substringBefore('=').trim().lowercase(Locale.ROOT)] = line.substringAfter('=').trim() + } + } + val title = values["name"]?.takeIf { it.isNotBlank() } ?: return null + return Mod( + title = title, + // bool.TryParse: either word in any case, and anything else keeps the default. + enabled = when (values["isenabled"]?.lowercase(Locale.ROOT)) { + "false" -> false + else -> true + }, + priority = values["priority"]?.toIntOrNull() ?: 0, + author = values["author"] ?: NO_ID, + modId = values["modid"]?.toIntOrNull() ?: -1, + ) + } + + /** What Mod.SaveToIniAsync writes: the same keys, with .NET's True/False. */ + fun iniText(mod: Mod): String = buildString { + append("[").append(SECTION).append("]\n") + append("Name = ").append(mod.title).append('\n') + append("Author = ").append(mod.author).append('\n') + append("ModID = ").append(mod.modId).append('\n') + append("IsEnabled = ").append(if (mod.enabled) "True" else "False").append('\n') + append("Priority = ").append(mod.priority).append('\n') + } + + fun save(modsDir: File, mod: Mod) { + val folder = folder(modsDir, mod) + if (!folder.isDirectory) throw IOException("The folder of ${mod.title} is missing: ${folder.absolutePath}") + val ini = File(folder, "${mod.title}.ini") + val temporary = File(folder, "${mod.title}.ini.tmp") + temporary.writeText(iniText(mod)) + if (!temporary.renameTo(ini)) { + temporary.delete() + ini.writeText(iniText(mod)) + } + } + + fun folder(modsDir: File, mod: Mod): File = File(modsDir, mod.title) + + /** ModManager.ValidateModName: a new name must be non-empty, unused (any case) and a valid folder name. */ + fun validateName(name: String, mods: List): NameProblem? { + val trimmed = name.trim() + return when { + trimmed.isEmpty() -> NameProblem.Empty + mods.any { it.title.equals(trimmed, ignoreCase = true) } -> NameProblem.Exists + trimmed.any { it in ILLEGAL_NAME_CHARACTERS || it.code < 32 } -> NameProblem.IllegalCharacters + else -> null + } + } + + /** A starting name for the import dialog: the file's name without its extensions, made valid. */ + fun suggestName(fileName: String, mods: List): String { + val base = fileName.substringAfterLast('/').substringBefore('.').trim() + .map { if (it in ILLEGAL_NAME_CHARACTERS || it.code < 32) ' ' else it } + .joinToString("").trim() + return base.takeIf { validateName(it, mods) == null } ?: "" + } + + // Changes + + /** + * ModManager.ImportModFilesAsync: the picked files become one new mod, enabled, below every + * existing one. A picked .zip is unpacked into it, as the PC does with a mod it downloads. + * The files are gathered beside the Mods folder's other entries and only moved into place once + * all are in, so a failed import leaves nothing behind. + */ + fun import(modsDir: File, title: String, sources: List, mods: List): Mod { + val name = title.trim() + validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") } + if (sources.isEmpty()) throw IOException("No files were chosen.") + sources.firstOrNull { source -> UNSUPPORTED_ARCHIVES.any { source.name.endsWith(it, ignoreCase = true) } }?.let { + throw IOException("${it.name} is an archive this headset cannot open. Unpack it on a computer and import its files, or import a .zip.") + } + modsDir.mkdirs() + val staging = File(modsDir, ".$name.importing") + staging.deleteRecursively() + if (!staging.mkdirs()) throw IOException("Could not create ${staging.absolutePath}") + try { + for (source in sources) { + val fileName = source.name.substringAfterLast('/').substringAfterLast('\\') + if (fileName.isBlank() || fileName == "." || fileName == "..") throw IOException("A chosen file has no usable name.") + if (fileName.endsWith(".zip", ignoreCase = true)) { + unpack(source, staging) + } else { + source.open().use { input -> File(staging, fileName).outputStream().use { input.copyTo(it) } } + } + } + if (staging.walkTopDown().none { it.isFile }) throw IOException("There was nothing to import.") + val mod = Mod(name, enabled = true, priority = (mods.maxOfOrNull { it.priority } ?: 0) + 1) + val target = folder(modsDir, mod) + // No mod has this name, so a folder under it is what an interrupted import left. + target.deleteRecursively() + if (!staging.renameTo(target)) throw IOException("Could not move the mod into ${target.absolutePath}") + save(modsDir, mod) + return mod + } finally { + staging.deleteRecursively() + } + } + + /** Unpacks a picked .zip into [destination], refusing entries that would land outside it. */ + private fun unpack(source: Source, destination: File) { + // ZipFile reads the central directory, which every zip has; streaming fails on some. + val archive = File(destination, ".archive.zip") + try { + source.open().use { input -> archive.outputStream().use { input.copyTo(it) } } + ZipFile(archive).use { zip -> + for (entry in zip.entries()) { + if (entry.isDirectory) continue + val relative = entry.name.replace('\\', '/').trimStart('/') + if (relative.isEmpty() || relative.split('/').any { it == ".." }) { + throw IOException("${source.name} has a file outside its own folder (${entry.name}).") + } + val file = File(destination, relative) + file.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> file.outputStream().use { input.copyTo(it) } } + } + } + } finally { + archive.delete() + } + } + + fun delete(modsDir: File, mod: Mod) { + val folder = folder(modsDir, mod) + if (folder.exists() && !folder.deleteRecursively()) throw IOException("Could not delete ${folder.absolutePath}") + } + + /** ModManager.RenameModAsync: the folder and its `.ini` take the new name. */ + fun rename(modsDir: File, mod: Mod, newTitle: String, mods: List): Mod { + val name = newTitle.trim() + if (name == mod.title) return mod + validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") } + val renamed = mod.copy(title = name) + val from = folder(modsDir, mod) + val to = folder(modsDir, renamed) + if (!from.renameTo(to)) throw IOException("Could not rename ${from.absolutePath}") + File(to, "${mod.title}.ini").delete() + save(modsDir, renamed) + return renamed + } + + /** + * ModManager.DecreasePriorityAsync (up) and IncreasePriorityAsync (down): the mod swaps + * priorities with its neighbour. Returns the two changed mods, or nothing at either end. + */ + fun move(mods: List, mod: Mod, up: Boolean): List { + val neighbour = if (up) { + mods.filter { it.priority < mod.priority }.maxByOrNull { it.priority } + } else { + mods.filter { it.priority > mod.priority }.minByOrNull { it.priority } + } ?: return emptyList() + return listOf(mod.copy(priority = neighbour.priority), neighbour.copy(priority = mod.priority)) + } + + // Launch + + /** + * ModsLaunchService.PrepareModsForLaunch: the file name each enabled mod's files take in the + * Patches folder, and where each comes from. Mods are walked from the bottom of the list up and + * a later one replaces an earlier one's file, so the top of the list wins. Names compare + * without case, as on the PC and on the headset's shared storage. + */ + fun plan(modsDir: File, mods: List): Map { + val files = LinkedHashMap>() + for (mod in mods.sortedWith(compareByDescending { it.priority }.thenByDescending { it.title.lowercase(Locale.ROOT) })) { + if (!mod.enabled) continue + val folder = folder(modsDir, mod) + if (!folder.isDirectory) continue + val metadata = File(folder, "${mod.title}.ini").absolutePath + val contents = folder.walkTopDown() + .filter { it.isFile && !it.absolutePath.equals(metadata, ignoreCase = true) } + .sortedBy { it.relativeTo(folder).path.lowercase(Locale.ROOT) } + for (file in contents) { + val name = launchName(mod.priority, file.name) + val key = name.lowercase(Locale.ROOT) + files[key] = (files[key]?.first ?: name) to file + } + } + return files.values.associate { it } + } + + /** + * ModsLaunchService.GetLaunchPatchFileName: a modding archive (`..szs`) is prefixed + * with its mod's priority, so Pulsar can resolve two mods patching the same archive. + */ + fun launchName(priority: Int, fileName: String): String { + if (!isModdingArchive(fileName)) return fileName + return "$priority.${stripPriorityPrefix(fileName)}" + } + + private fun isModdingArchive(fileName: String): Boolean { + if (!fileName.endsWith(".szs", ignoreCase = true)) return false + val stem = fileName.substring(0, fileName.length - ".szs".length) + val separator = stem.lastIndexOf('.') + return separator > 0 && separator + 1 < stem.length + } + + private fun stripPriorityPrefix(fileName: String): String { + val digits = fileName.takeWhile { it.isDigit() }.length + return if (digits > 0 && digits < fileName.length && fileName[digits] == '.') fileName.substring(digits + 1) else fileName + } + + /** ModsLaunchService.ShouldAskToClearTargetFolder: no mod enabled, yet the Patches folder holds files. */ + fun shouldAskToClear(mods: List, patchesDir: File): Boolean = + mods.none { it.enabled } && patchesDir.listFiles { file -> file.isFile }.orEmpty().isNotEmpty() + + /** + * ModsLaunchService.CopyFinalFiles: the Patches folder ends up holding exactly [plan]'s files. + * Loose files no mod provides go; a file whose size and time already match is not copied again. + */ + fun sync(patchesDir: File, plan: Map) { + patchesDir.mkdirs() + if (!patchesDir.isDirectory) throw IOException("Could not create ${patchesDir.absolutePath}") + val wanted = plan.keys.map { it.lowercase(Locale.ROOT) }.toSet() + for (file in patchesDir.listFiles { file -> file.isFile }.orEmpty()) { + if (file.name.lowercase(Locale.ROOT) !in wanted && !file.delete()) { + throw IOException("Could not remove ${file.absolutePath}") + } + } + for ((name, source) in plan) { + val target = File(patchesDir, name) + if (target.isFile && target.length() == source.length() && target.lastModified() == source.lastModified()) continue + source.copyTo(target, overwrite = true) + // Without the time, every start would copy everything again; that is all it costs. + target.setLastModified(source.lastModified()) + } + } + + /** ModsLaunchService with the folder-clearing answer: null on success, otherwise the message. */ + fun prepareForLaunch(modsDir: File, patchesDir: File, mods: List, clear: Boolean): String? = try { + when { + mods.any { it.enabled } -> sync(patchesDir, plan(modsDir, mods)) + clear && patchesDir.exists() && !patchesDir.deleteRecursively() -> + throw IOException("Could not clear ${patchesDir.absolutePath}") + } + null + } catch (e: IOException) { + e.message ?: e.toString() + } + + // Background work + + private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ModLibrary").apply { isDaemon = true } } + private val main by lazy { Handler(Looper.getMainLooper()) } + + /** True while an import runs; imports and launch preparation take turns on one thread. */ + @Volatile + var importing = false + private set + + /** Runs [work] off the main thread, one task at a time, and hands its result to [done] on the main thread. */ + fun background(work: () -> T, done: (Result) -> Unit) { + worker.execute { + val result = runCatching(work) + main.post { done(result) } + } + } + + fun importInBackground(modsDir: File, title: String, sources: List, done: (Result) -> Unit) { + importing = true + background({ import(modsDir, title, sources, load(modsDir)) }) { result -> + importing = false + done(result) + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt new file mode 100644 index 0000000..74734c6 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt @@ -0,0 +1,359 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.AlertDialog +import android.content.res.ColorStateList +import android.net.Uri +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import java.io.File +import java.io.IOException +import kotlin.math.roundToInt +import org.wiicompiled.quest.GameStorage +import org.wiicompiled.quest.R + +/** + * The launcher's Patches page, WheelWizard's mods page (Views/Pages/ModsPage.axaml.cs) without its + * mod browser: Import turns picked files into a named mod, and each mod can be switched on or off, + * moved up or down the list, renamed or deleted. What the list means for the game happens at + * Play, in [ModLibrary.prepareForLaunch]. + */ +class PatchesPage( + private val activity: Activity, + root: View, + private val pickFiles: () -> Unit, +) { + + private val empty: View = root.findViewById(R.id.patches_empty) + private val content: View = root.findViewById(R.id.patches_content) + private val rows: LinearLayout = root.findViewById(R.id.patches_rows) + private val count: TextView = root.findViewById(R.id.patches_count) + private val enableAll: Switch = root.findViewById(R.id.patches_enable_all) + private val headerImport: TextView = root.findViewById(R.id.patches_import) + private val importButtons = listOf(headerImport, root.findViewById(R.id.patches_empty_import)) + + private val modsDir: File get() = GameStorage.modsDirectory(activity) + + /** The list as read for the rows on screen, top first. */ + private var mods: List = emptyList() + + init { + for (button in importButtons) { + button.setOnClickListener { if (!ModLibrary.importing) pickFiles() } + } + styleSwitch(enableAll) + // A click, not a checked change: refresh() sets the switch without meaning to change every mod. + enableAll.setOnClickListener { setAllEnabled(enableAll.isChecked) } + root.findViewById(R.id.patches_enable_all_label).setOnClickListener { + enableAll.toggle() + setAllEnabled(enableAll.isChecked) + } + } + + /** Rebuilds the list from the Mods folder, which adb or the PC launcher's files may have changed. */ + fun refresh() { + mods = ModLibrary.load(modsDir) + val hasMods = mods.isNotEmpty() + // As on the PC, the page's own Import moves to the top bar once there is a list. + empty.visibility = if (hasMods) View.GONE else View.VISIBLE + content.visibility = if (hasMods) View.VISIBLE else View.GONE + headerImport.visibility = if (hasMods) View.VISIBLE else View.GONE + count.text = mods.size.toString() + enableAll.isChecked = mods.all { it.enabled } + + val importing = ModLibrary.importing + for (button in importButtons) { + button.isEnabled = !importing + button.alpha = if (importing) 0.45f else 1f + button.setText(if (importing) R.string.patches_importing else R.string.patches_import) + } + + rows.removeAllViews() + if (!hasMods) return + rows.addView( + note(), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(12) + }, + ) + mods.forEachIndexed { index, mod -> + rows.addView( + row(mod, index), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + if (index > 0) topMargin = dp(3) + }, + ) + } + } + + /** The files the player picked for Import: asks for the mod's name, then copies them in the background. */ + fun importPicked(uris: List) { + if (uris.isEmpty() || ModLibrary.importing) return + mods = ModLibrary.load(modsDir) + val sources = uris.map { uri -> + ModLibrary.Source(displayName(uri)) { + activity.contentResolver.openInputStream(uri) ?: throw IOException("Cannot read $uri") + } + } + // Typing on a headset is slow, so a single file offers its own name to start from. + val suggested = if (sources.size == 1) ModLibrary.suggestName(sources[0].name, mods) else "" + nameDialog(R.string.patches_name_title, null, suggested, R.string.patches_import, { ModLibrary.validateName(it, mods) }) { name -> + Log.i(TAG, "Importing ${sources.size} file(s) as mod $name") + ModLibrary.importInBackground(modsDir, name, sources) { result -> + if (activity.isDestroyed) return@importInBackground + refresh() + result.onSuccess { mod -> + Toast.makeText(activity, activity.getString(R.string.patches_installed, mod.title), Toast.LENGTH_LONG).show() + }.onFailure { failure -> + Log.w(TAG, "Mod import failed", failure) + AlertDialog.Builder(activity) + .setTitle(R.string.patches_import_failed) + .setMessage(failure.message ?: failure.toString()) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + refresh() + } + } + + private fun row(mod: ModLibrary.Mod, index: Int): View { + val last = mods.size - 1 + val toggle = Switch(activity).apply { + isChecked = mod.enabled + styleSwitch(this) + contentDescription = mod.title + setOnCheckedChangeListener { _, checked -> setModEnabled(mod, checked) } + } + val title = TextView(activity).apply { + text = mod.title + setTextColor(activity.getColor(R.color.neutral_100)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + } + return LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(58) + setPadding(dp(14), dp(6), dp(8), dp(6)) + background = activity.getDrawable( + when { + last == 0 -> R.drawable.bg_row_single + index == 0 -> R.drawable.bg_row_top + index == last -> R.drawable.bg_row_bottom + else -> R.drawable.bg_row_middle + }, + ) + addView(toggle) + addView(title, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(14) + marginEnd = dp(8) + }) + addView(iconButton(R.drawable.ic_chevron_up, R.string.patches_move_up, index > 0) { move(mod, up = true) }) + addView(iconButton(R.drawable.ic_chevron_down, R.string.patches_move_down, index < last) { move(mod, up = false) }) + addView(iconButton(R.drawable.ic_more, R.string.patches_more, true) { anchor -> showMenu(anchor, mod) }) + setOnClickListener { toggle.toggle() } + } + } + + private fun iconButton(icon: Int, description: Int, enabled: Boolean, onClick: (View) -> Unit) = ImageView(activity).apply { + setImageResource(icon) + imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_300)) + background = activity.getDrawable(R.drawable.bg_nav_item) + contentDescription = activity.getString(description) + setPadding(dp(10), dp(10), dp(10), dp(10)) + isEnabled = enabled + alpha = if (enabled) 1f else 0.3f + setOnClickListener(onClick) + layoutParams = LinearLayout.LayoutParams(dp(42), dp(42)) + } + + private fun note() = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + background = activity.getDrawable(R.drawable.bg_banner_info) + setPadding(dp(14), dp(10), dp(14), dp(10)) + addView( + TextView(activity).apply { + setText(R.string.patches_note) + setTextColor(activity.getColor(R.color.neutral_300)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + }, + ) + } + + private fun showMenu(anchor: View, mod: ModLibrary.Mod) { + PopupMenu(activity, anchor).apply { + menu.add(0, MENU_RENAME, 0, R.string.patches_rename) + menu.add(0, MENU_DELETE, 1, R.string.patches_delete) + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_RENAME -> rename(mod) + MENU_DELETE -> delete(mod) + } + true + } + show() + } + } + + private fun setModEnabled(mod: ModLibrary.Mod, enabled: Boolean) { + // Saved in place rather than rebuilt, so the switch keeps its animation. + val changed = mod.copy(enabled = enabled) + if (!change { ModLibrary.save(modsDir, changed) }) { + refresh() + return + } + mods = mods.map { if (it.title == mod.title) changed else it } + enableAll.isChecked = mods.all { it.enabled } + } + + private fun setAllEnabled(enabled: Boolean) { + change { + for (mod in mods) { + if (mod.enabled != enabled) ModLibrary.save(modsDir, mod.copy(enabled = enabled)) + } + } + refresh() + } + + private fun move(mod: ModLibrary.Mod, up: Boolean) { + change { ModLibrary.move(mods, mod, up).forEach { ModLibrary.save(modsDir, it) } } + refresh() + } + + private fun rename(mod: ModLibrary.Mod) { + nameDialog( + R.string.patches_rename_title, + activity.getString(R.string.patches_rename_message, mod.title), + mod.title, + R.string.patches_rename, + { name -> if (name.trim() == mod.title) null else ModLibrary.validateName(name, mods) }, + ) { name -> + change { ModLibrary.rename(modsDir, mod, name, mods) } + refresh() + } + } + + private fun delete(mod: ModLibrary.Mod) { + AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.patches_delete_title, mod.title)) + .setMessage(R.string.patches_delete_message) + .setPositiveButton(R.string.patches_delete) { _, _ -> + change { ModLibrary.delete(modsDir, mod) } + refresh() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + /** Runs one change to the Mods folder; false, with the reason shown, when it failed. */ + private fun change(edit: () -> Unit): Boolean = try { + edit() + true + } catch (e: IOException) { + Log.w(TAG, "Mod change failed", e) + Toast.makeText(activity, activity.getString(R.string.patches_change_failed, e.message ?: e.toString()), Toast.LENGTH_LONG).show() + false + } + + /** + * WheelWizard's TextInputWindow: a name field that keeps the dialog open, with the reason shown, + * until the name is one [validate] accepts. + */ + private fun nameDialog( + title: Int, + message: String?, + initial: String, + positive: Int, + validate: (String) -> ModLibrary.NameProblem?, + onAccept: (String) -> Unit, + ) { + val input = EditText(activity).apply { + setText(initial) + setSelection(text.length) + setHint(R.string.patches_name_hint) + setTextColor(activity.getColor(R.color.neutral_100)) + setHintTextColor(activity.getColor(R.color.neutral_500)) + isSingleLine = true + imeOptions = EditorInfo.IME_ACTION_DONE + } + val container = FrameLayout(activity).apply { + setPadding(dp(22), dp(8), dp(22), 0) + addView(input) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(title) + .apply { message?.let { setMessage(it) } } + .setView(container) + .setPositiveButton(positive, null) + .setNegativeButton(android.R.string.cancel, null) + .create() + fun accept() { + val problem = validate(input.text.toString()) + if (problem != null) { + input.error = activity.getString( + when (problem) { + ModLibrary.NameProblem.Empty -> R.string.patches_name_empty + ModLibrary.NameProblem.Exists -> R.string.patches_name_exists + ModLibrary.NameProblem.IllegalCharacters -> R.string.patches_name_illegal + }, + ) + return + } + dialog.dismiss() + onAccept(input.text.toString().trim()) + } + input.setOnEditorActionListener { _, action, _ -> + if (action == EditorInfo.IME_ACTION_DONE) accept() + action == EditorInfo.IME_ACTION_DONE + } + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { accept() } + input.requestFocus() + } + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + dialog.show() + } + + private fun displayName(uri: Uri): String { + runCatching { + activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotBlank() }?.let { return it } + } + } + return uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file" + } + + private fun styleSwitch(switch: Switch) { + switch.thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300) + switch.trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600) + } + + private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(activity.getColor(checked), activity.getColor(unchecked)), + ) + + private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt() + + private companion object { + const val TAG = "WiiCompiledLauncher" + const val MENU_RENAME = 1 + const val MENU_DELETE = 2 + } +} diff --git a/android/app/src/main/res/drawable/ic_chevron_up.xml b/android/app/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 0000000..61626f5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_file_import.xml b/android/app/src/main/res/drawable/ic_file_import.xml new file mode 100644 index 0000000..5dadba0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_file_import.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_more.xml b/android/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..103e350 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_patches.xml b/android/app/src/main/res/drawable/ic_patches.xml new file mode 100644 index 0000000..d41e7fb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_patches.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 3c9b71a..245f874 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -61,6 +61,20 @@ android:text="@string/launcher_nav_home" /> + + + + + + + @@ -105,6 +119,11 @@ android:id="@+id/page_home" layout="@layout/page_home" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 771545f..9cca959 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ General Home + Patches Settings v%1$s @@ -95,6 +96,38 @@ Removes the game files, and if you tick them the built games and the Retro Rewind pack, so they can be set up again. Saves and settings stay. The game folder could not be prepared: %1$s + + Import + Importing… + No mods found + Mods can alter how the game works. Start importing your first mod by clicking the button below.\n\nAs on the computer, mods change Retro Rewind. Import the mod\'s files, or the .zip it came in. + Mods + Enable all + Each time Retro Rewind starts, the enabled mods are copied into its Patches folder. When two mods replace the same file, the one higher in the list wins. + Move up + Move down + More + Rename + Delete + Keep + Mod name: + Enter mod name… + Enter new name + Changing name from: %1$s + Mod name cannot be empty. + Mod name already exists. + Mod name contains illegal characters. + Mod \'%1$s\' installed successfully. + The mod could not be imported + The mod could not be changed: %1$s + Are you sure you want to delete %1$s? + Deleting is permanent and cannot be undone. + This headset has no file picker to choose mod files with. + Mods found + You are about to launch the game without mods. Do you want to clear your Patches folder? If you keep it, the game starts with the patches it holds still active. + Installing mods… + The mods could not be installed + VR Graphics diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index e9fee49..692b2ef 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -70,6 +70,24 @@ true + + +