From 51567c7987ce5cb5b5a903e9e2159556c0743fcb Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 01:12:02 +0200 Subject: [PATCH] Added Quest Launcher mod browser - Introduced new drawable resources for user and warning icons. - Created layout files for mod browser items, loading indicators, and the main mod browser page. - Implemented IDs for resource referencing in the layout. - Added unit tests for GameBanana API interactions, ensuring proper parsing of mod data and error handling. - Developed Rust module for handling mod archives, supporting extraction of 7z and RAR formats with error checking. --- android/app/build.gradle.kts | 2 + .../wiicompiled/quest/launcher/GameBanana.kt | 325 ++++++++++ .../quest/launcher/LauncherActivity.kt | 26 +- .../wiicompiled/quest/launcher/ModArchive.kt | 28 + .../wiicompiled/quest/launcher/ModBrowser.kt | 608 ++++++++++++++++++ .../quest/launcher/ModInstaller.kt | 145 +++++ .../wiicompiled/quest/launcher/ModLibrary.kt | 168 +++-- .../wiicompiled/quest/launcher/PatchesPage.kt | 171 +++-- .../quest/launcher/PatchesWidgets.kt | 88 +++ .../quest/launcher/RemoteImages.kt | 99 +++ .../quest/launcher/SettingsPage.kt | 1 + .../main/res/color/warning_button_content.xml | 6 + .../main/res/drawable/bg_badge_patches.xml | 6 + .../src/main/res/drawable/bg_banner_fade.xml | 9 + .../src/main/res/drawable/bg_browser_item.xml | 29 + .../res/drawable/bg_browser_item_patches.xml | 29 + .../main/res/drawable/bg_button_warning.xml | 28 + .../src/main/res/drawable/bg_search_field.xml | 18 + .../src/main/res/drawable/bg_state_box.xml | 6 + .../src/main/res/drawable/ic_arrow_left.xml | 13 + .../app/src/main/res/drawable/ic_banana.xml | 12 + .../app/src/main/res/drawable/ic_download.xml | 13 + .../main/res/drawable/ic_external_link.xml | 11 + android/app/src/main/res/drawable/ic_eye.xml | 13 + .../app/src/main/res/drawable/ic_heart.xml | 11 + .../app/src/main/res/drawable/ic_search.xml | 11 + android/app/src/main/res/drawable/ic_user.xml | 13 + .../src/main/res/drawable/ic_warning_tip.xml | 11 + .../src/main/res/layout/item_mod_browser.xml | 103 +++ .../res/layout/item_mod_browser_loading.xml | 12 + .../src/main/res/layout/page_mod_browser.xml | 439 +++++++++++++ .../app/src/main/res/layout/page_patches.xml | 292 +++++---- android/app/src/main/res/values/colors.xml | 12 + android/app/src/main/res/values/ids.xml | 5 + android/app/src/main/res/values/strings.xml | 43 +- android/app/src/main/res/values/themes.xml | 70 ++ .../quest/launcher/GameBananaTest.kt | 149 +++++ .../quest/launcher/ModLibraryTest.kt | 82 +++ android/nod-jni/Cargo.lock | 305 +++++++++ android/nod-jni/Cargo.toml | 9 +- android/nod-jni/src/archive.rs | 295 +++++++++ android/nod-jni/src/lib.rs | 32 +- docs/quest-port.md | 63 +- 43 files changed, 3532 insertions(+), 279 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/GameBanana.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/ModArchive.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/ModBrowser.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/ModInstaller.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesWidgets.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/RemoteImages.kt create mode 100644 android/app/src/main/res/color/warning_button_content.xml create mode 100644 android/app/src/main/res/drawable/bg_badge_patches.xml create mode 100644 android/app/src/main/res/drawable/bg_banner_fade.xml create mode 100644 android/app/src/main/res/drawable/bg_browser_item.xml create mode 100644 android/app/src/main/res/drawable/bg_browser_item_patches.xml create mode 100644 android/app/src/main/res/drawable/bg_button_warning.xml create mode 100644 android/app/src/main/res/drawable/bg_search_field.xml create mode 100644 android/app/src/main/res/drawable/bg_state_box.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_left.xml create mode 100644 android/app/src/main/res/drawable/ic_banana.xml create mode 100644 android/app/src/main/res/drawable/ic_download.xml create mode 100644 android/app/src/main/res/drawable/ic_external_link.xml create mode 100644 android/app/src/main/res/drawable/ic_eye.xml create mode 100644 android/app/src/main/res/drawable/ic_heart.xml create mode 100644 android/app/src/main/res/drawable/ic_search.xml create mode 100644 android/app/src/main/res/drawable/ic_user.xml create mode 100644 android/app/src/main/res/drawable/ic_warning_tip.xml create mode 100644 android/app/src/main/res/layout/item_mod_browser.xml create mode 100644 android/app/src/main/res/layout/item_mod_browser_loading.xml create mode 100644 android/app/src/main/res/layout/page_mod_browser.xml create mode 100644 android/app/src/main/res/values/ids.xml create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/GameBananaTest.kt create mode 100644 android/nod-jni/src/archive.rs diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 900710b..476f941 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -294,6 +294,8 @@ dependencies { // SDLActivity and libSDL3.so; the AAR is downloaded by Prepare-QuestDependencies.ps1. implementation(files("libs/SDL3-3.4.4.aar")) testImplementation("junit:junit:4.13.2") + // android.jar only stubs org.json; the GameBanana parsing tests need a real one. + testImplementation("org.json:json:20180813") androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBanana.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBanana.kt new file mode 100644 index 0000000..6e85530 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBanana.kt @@ -0,0 +1,325 @@ +package org.wiicompiled.quest.launcher + +import java.io.File +import java.io.IOException +import java.io.InterruptedIOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.security.MessageDigest +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.wiicompiled.quest.BuildConfig + +/** + * GameBanana, where WheelWizard's mod browser finds Mario Kart Wii mods (Features/GameBanana): the + * same two public API calls, a name search one page at a time and one mod's profile page with its + * files, and the download of one of those files. Parsing is kept apart from the network so it can + * be tested on its own. + * + * Values are read with plain `opt` and type checks: Android's org.json and the JVM one the unit + * tests run against disagree on JSON nulls (Android's optString gives "null"). + */ +object GameBanana { + + private const val API = "https://gamebanana.com/apiv12" + /** Mario Kart Wii on GameBanana (GameBananaSingletonService.MkGameId). */ + private const val GAME_ID = 5896 + /** What an empty search asks for, so the list opens on something (WheelWizard's own "featured" list). */ + private const val DEFAULT_SEARCH = "Mod" + /** What an empty search asks for with Patches only on (ModBrowserWindow.GetEffectiveSearchTerm). */ + private const val PATCHES_SEARCH = "Patches" + /** GameBanana refuses shorter search strings. */ + const val MIN_SEARCH_LENGTH = 2 + + private const val TIMEOUT_MS = 30_000 + private const val DOWNLOAD_ATTEMPTS = 3 + private const val DOWNLOAD_BUFFER = 64 * 1024 + + private val userAgent: String get() = "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}" + + /** One preview picture, in the sizes GameBanana keeps of it. */ + data class Image(val baseUrl: String, val file: String, val file220: String?, val file530: String?) { + /** About 220 pixels wide, as much as a list thumbnail shows. */ + val small: String get() = "$baseUrl/${file220 ?: file}" + /** About 530 pixels wide, for the banner and the gallery. */ + val medium: String get() = "$baseUrl/${file530 ?: file}" + } + + /** One search result: what WheelWizard's ModBrowserListItem shows of GameBananaModPreview. */ + data class Preview( + val id: Int, + val name: String, + val author: String, + val likes: Long, + val views: Long, + val usesPatches: Boolean, + val image: Image?, + ) + + /** One page of results, and whether it is the last. */ + data class Page(val mods: List, val complete: Boolean) + + /** One downloadable file of a mod (GameBananaModFiles), with what to check the download against. */ + data class ModFile(val name: String, val size: Long, val url: String, val md5: String?, val description: String) + + /** A mod's profile page (GameBananaModDetails), as the details pane shows it. */ + data class Details( + val id: Int, + val name: String, + val author: String, + val authorUrl: String?, + val profileUrl: String?, + val likes: Long, + val views: Long, + val downloads: Long, + val text: String, + val images: List, + val files: List, + ) { + /** Where ModContent's Report link goes. */ + val reportUrl: String get() = "https://gamebanana.com/support/add?s=Mod.$id" + } + + /** Thrown when a download's progress callback asks to stop; a socket timeout is not this. */ + class Cancelled : InterruptedIOException("Download cancelled") + + // Requests + + /** What GameBanana is asked for: an empty search still lists something. */ + fun effectiveSearch(term: String, patchesOnly: Boolean): String = when { + term.isNotBlank() -> term.trim() + patchesOnly -> PATCHES_SEARCH + else -> DEFAULT_SEARCH + } + + fun searchUrl(term: String, page: Int): String = + "$API/Util/Search/Results?_sSearchString=${encode(term)}&_idGameRow=$GAME_ID&_sModelName=Mod&_nPage=$page" + + fun detailsUrl(modId: Int): String = "$API/Mod/$modId/ProfilePage" + + private fun encode(text: String): String = URLEncoder.encode(text, "UTF-8").replace("+", "%20") + + // Parsing + + /** + * A search page as the browser lists it: only mods, and none with content ratings, which + * WheelWizard's browser leaves out as well. + */ + fun parsePage(json: String): Page = parsing { + val root = JSONObject(json) + val records = root.array("_aRecords") ?: throw IOException("GameBanana sent no search results.") + val mods = records.objects() + .filter { it.text("_sModelName") == "Mod" && !it.flag("_bHasContentRatings") } + .mapNotNull { record -> + Preview( + id = record.number("_idRow").toInt().takeIf { it > 0 } ?: return@mapNotNull null, + name = record.text("_sName") ?: return@mapNotNull null, + author = record.obj("_aSubmitter")?.text("_sName").orEmpty(), + likes = record.number("_nLikeCount"), + views = record.number("_nViewCount"), + usesPatches = usesPatches(tagTitles(record.opt("_aTags"))), + image = images(record).firstOrNull(), + ) + } + // Without the flag there is no way to ask for more, so the list stops rather than repeating. + Page(mods, complete = root.obj("_aMetadata")?.opt("_bIsComplete") as? Boolean ?: true) + } + + /** + * A mod's profile page. Its files are the current ones, or the archived ones when it has none. + * A private, removed or withheld mod, which searches can still list, only has a stub of a + * page; it is refused with the reason, where WheelWizard fails to read it at all. + */ + fun parseDetails(json: String): Details = parsing { + val root = JSONObject(json) + when { + root.flag("_bIsTrashed") -> throw IOException("GameBanana has removed this mod.") + root.flag("_bIsWithheld") -> throw IOException("GameBanana is withholding this mod for now.") + root.flag("_bIsPrivate") -> throw IOException("This mod is private on GameBanana, so its page and files cannot be shown.") + } + val submitter = root.obj("_aSubmitter") + Details( + id = root.number("_idRow").toInt(), + name = root.text("_sName") ?: throw IOException("GameBanana sent a mod without a name."), + author = submitter?.text("_sName").orEmpty(), + authorUrl = submitter?.text("_sProfileUrl"), + profileUrl = root.text("_sProfileUrl"), + likes = root.number("_nLikeCount"), + views = root.number("_nViewCount"), + downloads = root.number("_nDownloadCount"), + text = root.text("_sText").orEmpty(), + images = images(root), + files = files(root.array("_aFiles")).ifEmpty { files(root.array("_aArchivedFiles")) }, + ) + } + + /** + * GameBananaModPreview.UsesPatches: a tag titled Patch or Patches. Search results give tags + * as "Title: Value" strings ("Patches: True"), profile pages as objects. + */ + fun usesPatches(tagTitles: List): Boolean = tagTitles.any { tag -> + val title = tag.trim().substringBefore(':').trim() + title.equals("patch", ignoreCase = true) || title.equals("patches", ignoreCase = true) + } + + /** The reason GameBanana gives when it refuses a request, such as a search string that is too short. */ + fun errorMessage(json: String): String? = try { + val root = JSONObject(json) + val fields = root.obj("_aErrorData") + fields?.keys()?.asSequence()?.mapNotNull { fields.obj(it)?.text("_sErrorMessage") }?.firstOrNull() + ?: root.text("_sErrorMessage") + ?: root.text("_sErrorCode") + } catch (e: JSONException) { + null + } + + private fun tagTitles(tags: Any?): List = (tags as? JSONArray)?.let { array -> + (0 until array.length()).mapNotNull { index -> + when (val tag = array.opt(index)) { + is String -> tag + is JSONObject -> tag.text("_sTitle") + else -> null + } + } + }.orEmpty() + + private fun images(record: JSONObject): List = + record.obj("_aPreviewMedia")?.array("_aImages")?.objects().orEmpty().mapNotNull { image -> + val base = image.text("_sBaseUrl") ?: return@mapNotNull null + val file = image.text("_sFile") ?: return@mapNotNull null + Image(base, file, image.text("_sFile220"), image.text("_sFile530")) + } + + private fun files(array: JSONArray?): List = array?.objects().orEmpty().mapNotNull { file -> + ModFile( + name = file.text("_sFile") ?: return@mapNotNull null, + size = file.number("_nFilesize"), + url = file.text("_sDownloadUrl") ?: return@mapNotNull null, + md5 = file.text("_sMd5Checksum")?.takeIf { it.length == 32 }, + description = file.text("_sDescription").orEmpty(), + ) + } + + private fun parsing(block: () -> T): T = try { + block() + } catch (e: JSONException) { + throw IOException("GameBanana sent something this launcher cannot read.", e) + } + + private fun JSONObject.text(key: String): String? = (opt(key) as? String)?.takeIf { it.isNotBlank() } + private fun JSONObject.number(key: String): Long = when (val value = opt(key)) { + is Number -> value.toLong() + is String -> value.toLongOrNull() ?: 0 + else -> 0 + } + private fun JSONObject.flag(key: String): Boolean = opt(key) as? Boolean ?: false + private fun JSONObject.obj(key: String): JSONObject? = opt(key) as? JSONObject + private fun JSONObject.array(key: String): JSONArray? = opt(key) as? JSONArray + private fun JSONArray.objects(): List = (0 until length()).mapNotNull { opt(it) as? JSONObject } + + // Network; everything below blocks, so callers run it off the main thread. + + /** One page of Mario Kart Wii mods for [term], which [effectiveSearch] made. */ + fun search(term: String, page: Int): Page = parsePage(get(searchUrl(term, page))) + + fun details(modId: Int): Details = parseDetails(get(detailsUrl(modId))) + + private fun connect(url: String): HttpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", userAgent) + } + + private fun get(url: String): String { + val connection = connect(url) + try { + val code = try { + connection.responseCode + } catch (e: IOException) { + throw IOException("GameBanana could not be reached. Check the headset's internet connection.", e) + } + val ok = code in 200..299 + val body = (if (ok) connection.inputStream else connection.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty() + if (!ok) throw IOException(errorMessage(body)?.let { "GameBanana: $it" } ?: "GameBanana answered $code.") + return body + } finally { + connection.disconnect() + } + } + + /** + * Downloads [file] to [target] and checks it against the MD5 GameBanana lists for it (or the + * size, when there is no MD5), so a download cut short or damaged on the way never reaches the + * Mods folder. [progress] gets the bytes so far and the total, and returns false to cancel, + * which throws [Cancelled]. A failed attempt is tried again twice, as WheelWizard retries its + * downloads, and handed to [failedAttempt] first. + */ + fun download( + file: ModFile, + target: File, + failedAttempt: (Int, IOException) -> Unit = { _, _ -> }, + progress: (Long, Long) -> Boolean, + ) { + var failure: IOException? = null + for (attempt in 1..DOWNLOAD_ATTEMPTS) { + try { + downloadOnce(file, target, progress) + return + } catch (e: Cancelled) { + target.delete() + throw e + } catch (e: IOException) { + target.delete() + failure = e + failedAttempt(attempt, e) + if (attempt < DOWNLOAD_ATTEMPTS) { + Thread.sleep(1_000L * attempt) + if (!progress(0, file.size)) throw Cancelled() + } + } + } + throw failure ?: IOException("The download of ${file.name} failed.") + } + + private fun downloadOnce(file: ModFile, target: File, progress: (Long, Long) -> Boolean) { + val connection = connect(file.url) + // Android would ask for gzip, and the length it then reports is not the file's size. + connection.setRequestProperty("Accept-Encoding", "identity") + try { + val code = try { + connection.responseCode + } catch (e: IOException) { + throw IOException("GameBanana could not be reached to download ${file.name}. Check the headset's internet connection.", e) + } + if (code != HttpURLConnection.HTTP_OK) throw IOException("GameBanana answered $code to the download of ${file.name}.") + val total = connection.contentLengthLong.takeIf { it > 0 } ?: file.size + val digest = MessageDigest.getInstance("MD5") + var done = 0L + if (!progress(done, total)) throw Cancelled() + connection.inputStream.use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(DOWNLOAD_BUFFER) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + done += count + if (!progress(done, total)) throw Cancelled() + } + } + } + val md5 = digest.digest().joinToString("") { "%02x".format(it) } + when { + file.md5 != null && !md5.equals(file.md5, ignoreCase = true) -> + throw IOException("The download of ${file.name} does not match the checksum GameBanana lists for it.") + file.md5 == null && file.size > 0 && done != file.size -> + throw IOException("The download of ${file.name} stopped at $done of ${file.size} bytes.") + } + } finally { + connection.disconnect() + } + } +} 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 9eea75c..95af5da 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,8 +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, a Patches page that imports mods for Retro - * Rewind, and a Settings page that edits Config.toml. + * with a Home page that sets up and starts the game, a Patches page that finds mods for Retro + * Rewind on GameBanana or imports them, 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 @@ -161,6 +161,22 @@ class LauncherActivity : Activity() { Log.i(TAG, "Starting a game build requested over adb") GameSetupService.startBuild(this, GameProfile.selected(this)) } + // ...and open the mod browser, on a GameBanana mod when one is named, which they can also + // install as the dialogs would with its first file and suggested name. + if (BuildConfig.DEBUG && savedInstanceState == null && intent.hasExtra(EXTRA_DEBUG_MOD_BROWSER)) { + val modId = intent.getIntExtra(EXTRA_DEBUG_MOD_BROWSER, -1) + Log.i(TAG, "Opening the mod browser over adb" + if (modId >= 0) " on mod $modId" else "") + showPage(Page.Patches) + patches.showBrowser(true, modId.takeIf { it >= 0 }, install = intent.getBooleanExtra(EXTRA_DEBUG_INSTALL_MOD, false)) + } + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + // The mod browser sits in place of the Patches list, so Back returns to the list first. + if (page == Page.Patches && patches.back()) return + @Suppress("DEPRECATION") + super.onBackPressed() } override fun onResume() { @@ -172,6 +188,7 @@ class LauncherActivity : Activity() { setupKind = GameSetup.state.javaClass refresh() GameSetup.addListener(setupListener) + patches.attach() // The game's process can take a moment to go away after its activity // closes, which would still read as running. val runningAtResume = isGameRunning() @@ -182,6 +199,7 @@ class LauncherActivity : Activity() { override fun onPause() { GameSetup.removeListener(setupListener) + patches.detach() super.onPause() } @@ -726,5 +744,9 @@ class LauncherActivity : Activity() { const val PREFERENCES = "launcher" const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME" + /** An int: the GameBanana mod to show, or -1 for the browser's first search alone. */ + const val EXTRA_DEBUG_MOD_BROWSER = "org.wiicompiled.quest.debug.MOD_BROWSER" + /** With MOD_BROWSER naming a mod: install it as Download and Install would, without the dialogs. */ + const val EXTRA_DEBUG_INSTALL_MOD = "org.wiicompiled.quest.debug.INSTALL_MOD" } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModArchive.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModArchive.kt new file mode 100644 index 0000000..5a62c9e --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModArchive.kt @@ -0,0 +1,28 @@ +package org.wiicompiled.quest.launcher + +import java.io.IOException +import java.io.InterruptedIOException + +/** + * The .7z and .rar mod archives Android cannot open itself, unpacked by android/nod-jni + * (src/archive.rs: sevenz-rust2 and rars, both checking each file's CRC or hash). Only regular + * files are written, all below the destination. Blocks, so callers run it off the main thread. + */ +object ModArchive { + + init { + System.loadLibrary("nod_jni") + } + + fun interface Listener { + /** Bytes written so far out of [total]; return false to cancel. */ + fun update(done: Long, total: Long): Boolean + } + + /** + * Unpacks [archive], a 7z or RAR file, into the existing [outDir] and returns how many files + * it wrote. Throws [InterruptedIOException] when [listener] cancels. + */ + @Throws(IOException::class) + external fun extract(archive: String, outDir: String, listener: Listener): Long +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModBrowser.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModBrowser.kt new file mode 100644 index 0000000..9cd1736 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModBrowser.kt @@ -0,0 +1,608 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.PixelFormat +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.Html +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.format.Formatter +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.AbsListView +import android.widget.BaseAdapter +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import java.io.IOException +import java.util.concurrent.Executors +import org.wiicompiled.quest.GameStorage +import org.wiicompiled.quest.R + +/** + * WheelWizard's mod browser (Views/Popups/ModManagement/ModBrowserWindow and ModContent), shown in + * place of the Patches page's list: GameBanana's Mario Kart Wii mods, searched by name and listed a + * page at a time as the list scrolls, beside the chosen mod's pictures, description and Download + * and Install. + * + * As on the PC, an empty search lists "Mod" ("Patches" with Patches only on), mods that use + * patches come first (or alone, with Patches only), and a mod the browser installed shows as + * Installed with Uninstall beside it. Two things differ for a headset: the mod's name is asked for + * before the download rather than after it, so the rest runs without the player, and a mod with + * several files asks which one instead of taking the first, which is often an old version. + */ +class ModBrowser( + private val activity: Activity, + private val root: View, + private val close: () -> Unit, + /** The Mods folder changed: a mod was installed or uninstalled here. */ + private val modsChanged: () -> Unit, +) { + + private val search: EditText = root.findViewById(R.id.browser_search) + private val patchesOnly: Switch = root.findViewById(R.id.browser_patches_only) + private val list: ListView = root.findViewById(R.id.browser_results) + private val listMessage: View = root.findViewById(R.id.browser_results_message) + private val listMessageText: TextView = root.findViewById(R.id.browser_results_message_text) + private val retry: View = root.findViewById(R.id.browser_retry) + + private val detailsLoading: View = root.findViewById(R.id.browser_details_loading) + private val detailsEmpty: View = root.findViewById(R.id.browser_details_empty) + private val detailsEmptyTitle: TextView = root.findViewById(R.id.browser_details_empty_title) + private val detailsEmptyMessage: TextView = root.findViewById(R.id.browser_details_empty_message) + private val detailsView: View = root.findViewById(R.id.browser_details) + private val banner: ImageView = root.findViewById(R.id.browser_banner) + private val title: TextView = root.findViewById(R.id.browser_mod_title) + private val likes: TextView = root.findViewById(R.id.browser_mod_likes) + private val views: TextView = root.findViewById(R.id.browser_mod_views) + private val downloads: TextView = root.findViewById(R.id.browser_mod_downloads) + private val author: TextView = root.findViewById(R.id.browser_mod_author) + private val pageLink: View = root.findViewById(R.id.browser_mod_page) + private val report: View = root.findViewById(R.id.browser_mod_report) + private val install: TextView = root.findViewById(R.id.browser_install) + private val cancel: View = root.findViewById(R.id.browser_cancel) + private val uninstall: View = root.findViewById(R.id.browser_uninstall) + private val installNote: TextView = root.findViewById(R.id.browser_install_note) + private val detailsScroll: View = root.findViewById(R.id.browser_details_scroll) + private val imagesLabel: View = root.findViewById(R.id.browser_images_label) + private val imagesScroll: View = root.findViewById(R.id.browser_images_scroll) + private val images: LinearLayout = root.findViewById(R.id.browser_images) + private val description: TextView = root.findViewById(R.id.browser_mod_description) + + private val modsDir get() = GameStorage.modsDirectory(activity) + + // The list (ModBrowserWindow): every result loaded so far, and the ones shown. + private val loaded = mutableListOf() + private var shown: List = emptyList() + private var searchTerm = "" + private var nextPage = 1 + private var hasMore = true + private var loadingList = false + private var listError: String? = null + /** Pages fetched in a row while Patches only has found nothing to show. */ + private var pagesWithoutPatches = 0 + /** Bumped by every new search, so an answer to an older one is dropped. */ + private var listGeneration = 0 + /** Whether the first search has run: nothing is fetched until the browser is opened. */ + private var searched = false + + // The details (ModContent). + private var selectedId: Int? = null + private var details: GameBanana.Details? = null + /** The installed mod the browser put there for [details], if any. */ + private var installedMod: ModLibrary.Mod? = null + private var detailsGeneration = 0 + /** A mod an unattended test asked to install once its details are in ([showMod]). */ + private var installWhenLoaded: Int? = null + + private val adapter = Results() + + init { + root.findViewById(R.id.browser_back).setOnClickListener { close() } + list.adapter = adapter + list.setOnItemClickListener { _, _, position, _ -> + shown.getOrNull(position)?.let { mod -> + hideKeyboard() + // Choosing the same mod again only reloads it when it failed to load. + if (mod.id != selectedId || (details == null && detailsLoading.visibility != View.VISIBLE)) loadDetails(mod.id) + markSelection() + } + } + list.setOnScrollListener(object : AbsListView.OnScrollListener { + override fun onScrollStateChanged(view: AbsListView, scrollState: Int) = Unit + + override fun onScroll(view: AbsListView, first: Int, visibleCount: Int, total: Int) { + // ModBrowserWindow loads the next page when the list nears its end. ListView also + // calls this when the listener is set, before the browser was ever opened. + if (searched && total > 0 && first + visibleCount >= total - SCROLL_AHEAD) loadMore() + } + }) + search.setOnEditorActionListener { _, action, event -> + val enter = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN + if (action == EditorInfo.IME_ACTION_SEARCH || enter) { + runSearch() + true + } else { + false + } + } + root.findViewById(R.id.browser_search_button).setOnClickListener { runSearch() } + PatchesWidgets.styleSwitch(activity, patchesOnly) + patchesOnly.setOnClickListener { reload() } + root.findViewById(R.id.browser_patches_only_row).setOnClickListener { + patchesOnly.toggle() + reload() + } + retry.setOnClickListener { + listError = null + loadMore() + } + install.setOnClickListener { startInstall() } + cancel.setOnClickListener { ModInstaller.cancel() } + uninstall.setOnClickListener { uninstall() } + pageLink.setOnClickListener { details?.profileUrl?.let(::openLink) } + author.setOnClickListener { details?.authorUrl?.let(::openLink) } + report.setOnClickListener { details?.let { openLink(it.reportUrl) } } + description.movementMethod = LinkMovementMethod.getInstance() + } + + /** Shown again: the first search runs once, and what is installed may have changed meanwhile. */ + fun opened() { + if (!searched || (loaded.isEmpty() && !loadingList && listError == null)) reload() else renderList() + refreshInstalled() + } + + /** + * ModsPage's View Mod: a mod the browser installed, by its GameBanana id. With [install], an + * unattended test's, the mod is installed once it has loaded, with its first file and the + * name the dialog would suggest. + */ + fun showMod(modId: Int, install: Boolean = false) { + opened() + installWhenLoaded = if (install) modId else null + loadDetails(modId) + markSelection() + } + + /** ModInstaller moved on: the chosen mod's buttons follow it. */ + fun installStateChanged() { + if (details != null) renderInstall() + } + + // The list + + private fun runSearch() { + hideKeyboard() + val term = search.text.toString().trim() + if (term.isNotEmpty() && term.length < GameBanana.MIN_SEARCH_LENGTH) { + Toast.makeText(activity, R.string.browser_search_too_short, Toast.LENGTH_LONG).show() + return + } + searchTerm = term + reload() + } + + /** ModBrowserWindow.ReloadSearchResults: back to page 1 for the current search and switch. */ + private fun reload() { + searched = true + listGeneration++ + loaded.clear() + nextPage = 1 + hasMore = true + loadingList = false + listError = null + pagesWithoutPatches = 0 + shown = emptyList() + renderList() + list.setSelection(0) + loadMore() + } + + /** + * Whether another page can be asked for: GameBanana has more, nothing failed, and Patches only + * has not looked through [PATCHES_SEARCH_PAGES] pages in a row without finding one to show. + * A short list asks again after every page, since the list scrolls to its end by itself. + */ + private fun canLoadMore(): Boolean = + hasMore && listError == null && !(patchesOnly.isChecked && shown.isEmpty() && pagesWithoutPatches >= PATCHES_SEARCH_PAGES) + + private fun loadMore() { + if (loadingList || !canLoadMore()) return + loadingList = true + val generation = listGeneration + val term = GameBanana.effectiveSearch(searchTerm, patchesOnly.isChecked) + val page = nextPage + renderList() + network.execute { + val result = runCatching { GameBanana.search(term, page) } + root.post { + if (generation != listGeneration) return@post + loadingList = false + result.onSuccess { found -> + Log.i(TAG, "Mod browser: page $page for \"$term\" has ${found.mods.size} mods" + if (found.complete) ", the last" else "") + loaded += found.mods.filter { mod -> loaded.none { it.id == mod.id } } + nextPage = page + 1 + hasMore = !found.complete + shown = visibleMods() + // ModBrowserWindow.EnsurePatchesOnlyResultsAsync, bounded by canLoadMore: keep + // looking while Patches only has nothing to show yet. + if (patchesOnly.isChecked && shown.isEmpty()) pagesWithoutPatches++ + renderList() + if (patchesOnly.isChecked && shown.isEmpty()) loadMore() + }.onFailure { failure -> + Log.w(TAG, "Mod browser search failed", failure) + listError = failure.message ?: failure.toString() + renderList() + } + } + } + } + + /** ModBrowserWindow.ApplyModListFilters: Patches only keeps those; otherwise they come first. */ + private fun visibleMods(): List = + if (patchesOnly.isChecked) loaded.filter { it.usesPatches } else loaded.sortedByDescending { it.usesPatches } + + private fun renderList() { + adapter.notifyDataSetChanged() + markSelection() + val error = listError + when { + error != null -> { + listMessageText.text = activity.getString(R.string.browser_load_failed, error) + retry.visibility = View.VISIBLE + listMessage.visibility = View.VISIBLE + } + shown.isEmpty() && !loadingList && !canLoadMore() -> { + listMessageText.text = activity.getString(R.string.browser_no_results, GameBanana.effectiveSearch(searchTerm, patchesOnly.isChecked)) + retry.visibility = View.GONE + listMessage.visibility = View.VISIBLE + } + else -> listMessage.visibility = View.GONE + } + } + + private fun markSelection() { + val position = shown.indexOfFirst { it.id == selectedId } + if (position >= 0) list.setItemChecked(position, true) else list.clearChoices() + } + + private inner class Results : BaseAdapter() { + /** The mods, then a loading row while more can come (the PC's "LOADING" entry). */ + override fun getCount(): Int = shown.size + if (loadingList || canLoadMore()) 1 else 0 + + override fun getItem(position: Int): Any? = shown.getOrNull(position) + + override fun getItemId(position: Int): Long = shown.getOrNull(position)?.id?.toLong() ?: -1L + + override fun hasStableIds(): Boolean = true + + override fun getViewTypeCount(): Int = 2 + + override fun getItemViewType(position: Int): Int = if (position < shown.size) 0 else 1 + + override fun isEnabled(position: Int): Boolean = position < shown.size + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val mod = shown.getOrNull(position) + ?: return convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_mod_browser_loading, parent, false) + val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_mod_browser, parent, false) + row.findViewById(R.id.browser_item_title).text = mod.name + row.findViewById(R.id.browser_item_author).text = activity.getString(R.string.browser_by, mod.author) + row.findViewById(R.id.browser_item_likes).text = mod.likes.toString() + row.findViewById(R.id.browser_item_views).text = mod.views.toString() + row.findViewById(R.id.browser_item_patches).visibility = if (mod.usesPatches) View.VISIBLE else View.GONE + row.setBackgroundResource(if (mod.usesPatches) R.drawable.bg_browser_item_patches else R.drawable.bg_browser_item) + val image = row.findViewById(R.id.browser_item_image) + image.setBackgroundColor(activity.getColor(if (mod.usesPatches) R.color.patches_image else R.color.neutral_700)) + RemoteImages.into(image, mod.image?.small, dp(THUMBNAIL_WIDTH_DP)) + return row + } + } + + // The details + + /** ModContent.LoadModDetailsAsync: the list stays usable, and only the latest choice is shown. */ + private fun loadDetails(modId: Int) { + val generation = ++detailsGeneration + selectedId = modId + details = null + showDetailsState(loading = true) + network.execute { + val result = runCatching { GameBanana.details(modId) } + root.post { + if (generation != detailsGeneration) return@post + result.onSuccess { found -> + details = found + renderDetails(found) + if (installWhenLoaded == found.id) { + installWhenLoaded = null + val file = found.files.firstOrNull() + val name = ModLibrary.nameFrom(found.name) + if (file != null && installedMod == null && ModLibrary.validateName(name, ModLibrary.load(modsDir)) == null) { + install(found, file, name) + } else { + Log.w(TAG, "Mod ${found.id} cannot be installed unattended: no file, installed already, or its name is taken") + } + } + }.onFailure { failure -> + Log.w(TAG, "Mod browser could not load mod $modId", failure) + detailsEmptyTitle.setText(R.string.browser_details_failed) + detailsEmptyMessage.text = failure.message ?: failure.toString() + showDetailsState(loading = false) + } + } + } + } + + private fun showDetailsState(loading: Boolean) { + detailsLoading.visibility = if (loading) View.VISIBLE else View.GONE + detailsView.visibility = if (!loading && details != null) View.VISIBLE else View.GONE + detailsEmpty.visibility = if (!loading && details == null) View.VISIBLE else View.GONE + } + + private fun renderDetails(mod: GameBanana.Details) { + title.text = mod.name + likes.text = mod.likes.toString() + views.text = mod.views.toString() + downloads.text = mod.downloads.toString() + author.text = mod.author + author.visibility = if (mod.author.isNotBlank()) View.VISIBLE else View.GONE + pageLink.visibility = if (mod.profileUrl != null) View.VISIBLE else View.GONE + + // The first picture is the banner too, as in ModContent. + RemoteImages.into(banner, mod.images.firstOrNull()?.medium, dp(BANNER_WIDTH_DP)) + images.removeAllViews() + for (image in mod.images) { + val view = ImageView(activity).apply { + adjustViewBounds = true + scaleType = ImageView.ScaleType.FIT_CENTER + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + images.addView(view, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply { + marginEnd = dp(8) + }) + RemoteImages.into(view, image.medium, dp(GALLERY_WIDTH_DP)) + } + imagesLabel.visibility = if (mod.images.isEmpty()) View.GONE else View.VISIBLE + imagesScroll.visibility = imagesLabel.visibility + description.text = description(mod.text, detailsGeneration) + detailsScroll.scrollTo(0, 0) + + refreshInstalled() + showDetailsState(loading = false) + } + + /** Whether the chosen mod is in the Mods folder, read again after anything that may change it. */ + private fun refreshInstalled() { + val mod = details ?: return + installedMod = ModLibrary.installed(ModLibrary.load(modsDir), mod.id) + renderInstall() + } + + /** ModContent.UpdateDownloadButtonState, plus the progress of an install that is running. */ + private fun renderInstall() { + val mod = details ?: return + val state = ModInstaller.state + val running = when (state) { + is ModInstaller.State.Downloading -> state.modId == mod.id + is ModInstaller.State.Installing -> state.modId == mod.id + ModInstaller.State.Idle -> false + } + val busy = ModInstaller.busy || ModLibrary.importing + install.text = when { + state is ModInstaller.State.Downloading && running -> activity.getString(R.string.browser_downloading, percent(state.done, state.total)) + state is ModInstaller.State.Installing && running && state.total > 0 -> + activity.getString(R.string.browser_installing_percent, percent(state.done, state.total)) + running -> activity.getString(R.string.browser_installing) + installedMod != null -> activity.getString(R.string.browser_installed) + else -> activity.getString(R.string.browser_install) + } + install.isEnabled = !running && installedMod == null && mod.files.isNotEmpty() && !busy + cancel.visibility = if (running) View.VISIBLE else View.GONE + uninstall.visibility = if (!running && installedMod != null) View.VISIBLE else View.GONE + uninstall.isEnabled = !busy + val note = when { + running || installedMod != null -> null + mod.files.isEmpty() -> activity.getString(R.string.browser_no_files) + busy -> activity.getString(R.string.browser_busy) + else -> null + } + installNote.text = note + installNote.visibility = if (note != null) View.VISIBLE else View.GONE + } + + private fun percent(done: Long, total: Long): Int = if (total > 0) (done * 100 / total).toInt().coerceIn(0, 100) else 0 + + // Install and uninstall + + private fun startInstall() { + val mod = details ?: return + if (ModInstaller.busy || ModLibrary.importing || installedMod != null) return + when (mod.files.size) { + 0 -> return + 1 -> askName(mod, mod.files[0]) + else -> { + val labels = mod.files.map { file -> + val size = Formatter.formatShortFileSize(activity, file.size) + if (file.description.isBlank()) "${file.name} ($size)" else "${file.name} ($size)\n${file.description}" + } + AlertDialog.Builder(activity) + .setTitle(R.string.browser_choose_file) + .setItems(labels.toTypedArray()) { _, which -> askName(mod, mod.files[which]) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + } + + /** WheelWizard's TextInputWindow, asked before the download so the rest runs by itself. */ + private fun askName(mod: GameBanana.Details, file: GameBanana.ModFile) { + val message = activity.getString(R.string.browser_install_note, file.name, Formatter.formatShortFileSize(activity, file.size)) + PatchesWidgets.nameDialog(activity, R.string.patches_name_title, message, ModLibrary.nameFrom(mod.name), R.string.browser_install, { + ModLibrary.validateName(it, ModLibrary.load(modsDir)) + }) { name -> install(mod, file, name) } + } + + /** Downloads and installs [file] as [name]; the outcome shows as the Patches page's import's does. */ + private fun install(mod: GameBanana.Details, file: GameBanana.ModFile, name: String) { + val started = ModInstaller.start(activity, mod, file, name) { result -> + if (activity.isDestroyed) return@start + modsChanged() + refreshInstalled() + result?.onSuccess { installed -> + Toast.makeText(activity, activity.getString(R.string.patches_installed, installed.title), Toast.LENGTH_LONG).show() + }?.onFailure { failure -> + AlertDialog.Builder(activity) + .setTitle(R.string.browser_install_failed) + .setMessage(failure.message ?: failure.toString()) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + if (!started) renderInstall() + } + + /** ModContent.UnInstall_Click, with the Patches page's confirmation, since deleting is final. */ + private fun uninstall() { + val mod = installedMod ?: return + if (ModInstaller.busy || ModLibrary.importing) return + AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.patches_delete_title, mod.title)) + .setMessage(R.string.patches_delete_message) + .setPositiveButton(R.string.patches_delete) { _, _ -> + try { + ModLibrary.delete(modsDir, mod) + } catch (e: IOException) { + Log.w(TAG, "Uninstall failed", e) + Toast.makeText(activity, activity.getString(R.string.patches_change_failed, e.message ?: e.toString()), Toast.LENGTH_LONG).show() + } + modsChanged() + refreshInstalled() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + // Description + + /** + * The mod's description, GameBanana's HTML: its colour classes get the colours WheelWizard's + * stylesheet gives them, pictures load in after the text, and links open in the browser. + */ + private fun description(html: String, generation: Int): Spanned { + var text = html + for ((name, color) in HTML_COLORS) text = text.replace("class=\"$name\"", "style=\"color:$color\"") + val parsed = SpannableStringBuilder(Html.fromHtml(text, Html.FROM_HTML_MODE_COMPACT, DescriptionImages(generation), null)) + for (span in parsed.getSpans(0, parsed.length, URLSpan::class.java)) { + val url = span.url + val start = parsed.getSpanStart(span) + val end = parsed.getSpanEnd(span) + parsed.removeSpan(span) + parsed.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) = openLink(url) + }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return parsed + } + + private inner class DescriptionImages(private val generation: Int) : Html.ImageGetter { + override fun getDrawable(source: String?): Drawable { + val holder = LateDrawable() + val url = source?.trim()?.let { if (it.startsWith("//")) "https:$it" else it } + if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) return holder + RemoteImages.fetch(url, dp(DESCRIPTION_IMAGE_WIDTH_DP)) { bitmap -> + if (generation != detailsGeneration) return@fetch + val width = (description.width - description.paddingStart - description.paddingEnd).takeIf { it > 0 } ?: dp(DESCRIPTION_IMAGE_WIDTH_DP) + holder.set(BitmapDrawable(activity.resources, bitmap), width) + // Setting the same text again lays it out around the picture's new size. + description.text = description.text + } + return holder + } + } + + /** A picture in the description that arrives after the text is on screen. */ + private class LateDrawable : Drawable() { + private var picture: Drawable? = null + + fun set(drawable: Drawable, maxWidth: Int) { + val scale = minOf(1f, maxWidth.toFloat() / drawable.intrinsicWidth.coerceAtLeast(1)) + val width = (drawable.intrinsicWidth * scale).toInt() + val height = (drawable.intrinsicHeight * scale).toInt() + drawable.setBounds(0, 0, width, height) + setBounds(0, 0, width, height) + picture = drawable + } + + override fun draw(canvas: Canvas) { + picture?.draw(canvas) + } + + override fun setAlpha(alpha: Int) = Unit + + override fun setColorFilter(colorFilter: ColorFilter?) = Unit + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + } + + // Helpers + + private fun openLink(url: String) { + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.browser_no_browser, Toast.LENGTH_LONG).show() + } + } + + private fun hideKeyboard() { + search.clearFocus() + activity.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(search.windowToken, 0) + } + + private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value) + + private companion object { + const val TAG = "WiiCompiledLauncher" + /** Rows from the end at which the next page is asked for. */ + const val SCROLL_AHEAD = 3 + /** How many pages Patches only looks through for a first result before it stops. */ + const val PATCHES_SEARCH_PAGES = 8 + const val THUMBNAIL_WIDTH_DP = 96 + const val BANNER_WIDTH_DP = 480 + const val GALLERY_WIDTH_DP = 300 + const val DESCRIPTION_IMAGE_WIDTH_DP = 440 + + /** The classes GameBanana colours text with, in WheelWizard's HtmlPanel stylesheet colours. */ + val HTML_COLORS = listOf( + "BlueColor" to "#3489AA", + "GreenColor" to "#34EA3A", + "OrangeColor" to "#EA9834", + "GreyColor" to "#6C7389", + "RedColor" to "#F7524D", + "PurpleColor" to "#9A4DF7", + ) + + val network = Executors.newFixedThreadPool(2) { runnable -> Thread(runnable, "ModBrowser").apply { isDaemon = true } } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModInstaller.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModInstaller.kt new file mode 100644 index 0000000..76d0211 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModInstaller.kt @@ -0,0 +1,145 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import java.io.File +import java.io.IOException +import java.util.concurrent.Executors +import org.wiicompiled.quest.GameStorage + +/** + * The mod browser's Download and Install (ModContent.DownloadAndInstallCurrentModAsync): one of a + * mod's files is downloaded from GameBanana into the app's cache, checked, and installed as a new + * mod carrying its author and GameBanana id, which is how the browser knows later that it is + * installed. The download has its own thread, so Play never waits behind it; the install then takes + * its turn on ModLibrary's thread like any import. One runs at a time, holding ModLibrary's import + * slot from start to end, so the Patches page's Import and this wait for each other. + */ +object ModInstaller { + + sealed class State { + data object Idle : State() + + /** [done] of [total] bytes fetched for the GameBanana mod [modId]. */ + data class Downloading(val modId: Int, val done: Long, val total: Long) : State() + + /** [done] of [total] bytes unpacked into the Mods folder; 0 of 0 before it knows. */ + data class Installing(val modId: Int, val done: Long, val total: Long) : State() + } + + private const val TAG = "WiiCompiledLauncher" + private const val DOWNLOAD_DIRECTORY = "mod-download" + private const val PROGRESS_INTERVAL_MS = 100L + + /** Changed and read on the main thread. */ + var state: State = State.Idle + private set + + val busy: Boolean get() = state != State.Idle + + private val listeners = mutableListOf<(State) -> Unit>() + private val main by lazy { Handler(Looper.getMainLooper()) } + private val downloader = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ModDownload").apply { isDaemon = true } } + + @Volatile + private var cancelRequested = false + + @Volatile + private var lastReport = 0L + + fun addListener(listener: (State) -> Unit) { + listeners += listener + } + + fun removeListener(listener: (State) -> Unit) { + listeners -= listener + } + + /** Asks the running download or install to stop; its folder in Mods is never left half-made. */ + fun cancel() { + if (busy) cancelRequested = true + } + + /** + * Downloads [file] of [mod] and installs it as the mod [title]. [done] gets, on the main + * thread, the new mod, the failure, or null when the player cancelled. Returns false without + * starting when an import or another install is already running. + */ + fun start(context: Context, mod: GameBanana.Details, file: GameBanana.ModFile, title: String, done: (Result?) -> Unit): Boolean { + if (busy || ModLibrary.importing) return false + ModLibrary.importing = true + cancelRequested = false + val modsDir = GameStorage.modsDirectory(context) + // WheelWizard's temporary mods folder: emptied before each download and after each install. + val folder = File(context.cacheDir, DOWNLOAD_DIRECTORY) + val author = mod.author.ifBlank { ModLibrary.NO_ID } + Log.i(TAG, "Installing GameBanana mod ${mod.id} (${file.name}, ${file.size} bytes) as $title") + update(State.Downloading(mod.id, 0, file.size)) + downloader.execute { + val started = SystemClock.elapsedRealtime() + val archive = try { + folder.deleteRecursively() + if (!folder.mkdirs() && !folder.isDirectory) throw IOException("Could not create ${folder.absolutePath}") + File(folder, safeFileName(file.name)).also { target -> + val retried: (Int, IOException) -> Unit = { attempt, failure -> + Log.w(TAG, "Download attempt $attempt of ${file.name} failed", failure) + } + GameBanana.download(file, target, retried) { bytes, total -> + report(State.Downloading(mod.id, bytes, total)) + !cancelRequested + } + } + } catch (e: Exception) { + main.post { finish(folder, Result.failure(e), done) } + return@execute + } + Log.i(TAG, "Downloaded ${file.name} in ${SystemClock.elapsedRealtime() - started} ms") + main.post { if (busy) update(State.Installing(mod.id, 0, 0)) } + ModLibrary.background({ + if (cancelRequested) throw GameBanana.Cancelled() + val unpacking = SystemClock.elapsedRealtime() + val source = ModLibrary.Source(file.name, archive) { archive.inputStream() } + val progress = ModArchive.Listener { bytes, total -> + report(State.Installing(mod.id, bytes, total)) + !cancelRequested + } + ModLibrary.import(modsDir, title, listOf(source), ModLibrary.load(modsDir), author, mod.id, progress).also { + Log.i(TAG, "Unpacked ${file.name} into Mods in ${SystemClock.elapsedRealtime() - unpacking} ms") + } + }) { result -> finish(folder, result, done) } + } + return true + } + + /** On the main thread: the download folder goes, and the outcome is handed over. */ + private fun finish(folder: File, result: Result, done: (Result?) -> Unit) { + folder.deleteRecursively() + ModLibrary.importing = false + val cancelled = result.isFailure && cancelRequested + cancelRequested = false + result.onSuccess { Log.i(TAG, "Installed mod ${it.title} (GameBanana ${it.modId})") } + result.onFailure { if (!cancelled) Log.w(TAG, "Mod install failed", it) else Log.i(TAG, "Mod install cancelled") } + update(State.Idle) + done(if (cancelled) null else result) + } + + /** Progress from a worker thread, passed on a few times a second at most. */ + private fun report(next: State) { + val now = SystemClock.uptimeMillis() + if (now - lastReport < PROGRESS_INTERVAL_MS) return + lastReport = now + main.post { if (busy) update(next) } + } + + private fun update(next: State) { + state = next + listeners.toList().forEach { it(next) } + } + + /** GameBanana's name for the file, kept to one path segment. */ + private fun safeFileName(name: String): String = + name.substringAfterLast('/').substringAfterLast('\\').takeIf { it.isNotBlank() && it != "." && it != ".." } ?: "mod" +} 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 index fe5abcf..972f478 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt @@ -5,14 +5,16 @@ import android.os.Looper import java.io.File import java.io.IOException import java.io.InputStream +import java.io.InterruptedIOException 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. + * The mods imported or installed from the mod browser 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), @@ -21,7 +23,10 @@ import java.util.zip.ZipFile */ object ModLibrary { - /** One imported mod, as its `.ini` describes it. Author and ModID only come from the PC's mod browser. */ + /** + * One imported mod, as its `.ini` describes it. Author and ModID (its GameBanana id) only come + * from a mod browser, this launcher's or the PC's. + */ data class Mod( val title: String, val enabled: Boolean, @@ -30,19 +35,32 @@ object ModLibrary { 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) + /** + * One file for an import, picked or downloaded: its display name and how to read it, and the + * file itself when it is already on disk, so a downloaded archive is not copied again. + */ + class Source(val name: String, val file: File? = null, val open: () -> InputStream) enum class NameProblem { Empty, Exists, IllegalCharacters } + /** The archive formats GameBanana serves, told apart by their first bytes. */ + enum class ArchiveKind { Zip, SevenZip, Rar } + private const val SECTION = "Mod" - private const val NO_ID = "-1" + 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() + private val WHITESPACE = Regex("\\s+") - /** Archives the PC unpacks with SharpCompress; without it, only .zip can be opened here. */ - private val UNSUPPORTED_ARCHIVES = listOf(".7z", ".rar") + /** The archives the PC unpacks with SharpCompress: .zip here, .7z and .rar through [ModArchive]. */ + private val ARCHIVE_EXTENSIONS = listOf(".zip", ".7z", ".rar") + + private val ZIP_SIGNATURES = listOf(byteArrayOf(0x50, 0x4B, 0x03, 0x04), byteArrayOf(0x50, 0x4B, 0x05, 0x06)) + private val SEVEN_ZIP_SIGNATURE = byteArrayOf(0x37, 0x7A, 0xBC.toByte(), 0xAF.toByte(), 0x27, 0x1C) + /** Followed by 0 for RAR 1.5 to 4.x and by 1, 0 for RAR5; nod-jni reads both. */ + private val RAR_SIGNATURE = byteArrayOf(0x52, 0x61, 0x72, 0x21, 0x1A, 0x07) + private const val COPY_BUFFER = 64 * 1024 // Metadata @@ -124,27 +142,48 @@ object ModLibrary { /** 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() + val base = legalName(fileName.substringAfterLast('/').substringBefore('.')) return base.takeIf { validateName(it, mods) == null } ?: "" } + /** + * A starting name for the browser's install dialog: the mod's GameBanana name with what a + * folder name cannot hold turned into spaces. The PC offers the raw name, which a headset + * keyboard would then have to fix; a name already taken is left for the dialog to point out. + */ + fun nameFrom(title: String): String = legalName(title) + + private fun legalName(text: String): String = + text.map { if (it in ILLEGAL_NAME_CHARACTERS || it.code < 32) ' ' else it } + .joinToString("") + .replace(WHITESPACE, " ") + .trim() + + /** ModManager.IsModInstalled: the mod a browser installed with this GameBanana id, if any. */ + fun installed(mods: List, modId: Int): Mod? = if (modId < 0) null else mods.firstOrNull { it.modId == modId } + // 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. + * ModManager.ImportModFilesAsync and InstallModFromFileAsync: the files become one new mod, + * enabled, below every existing one. A .zip, .7z or .rar among them is unpacked into it, as the + * PC does with a mod it downloads; [author] and [modId] are what the mod browser knows of it. * 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. + * all are in, so a failed import leaves nothing behind. [progress] follows the unpacking of + * archives and returns false to cancel it. */ - fun import(modsDir: File, title: String, sources: List, mods: List): Mod { + fun import( + modsDir: File, + title: String, + sources: List, + mods: List, + author: String = NO_ID, + modId: Int = -1, + progress: ModArchive.Listener? = null, + ): 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() @@ -153,14 +192,14 @@ object ModLibrary { 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) + if (ARCHIVE_EXTENSIONS.any { fileName.endsWith(it, ignoreCase = true) }) { + unpack(source, staging, progress) } 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 mod = Mod(name, enabled = true, priority = (mods.maxOfOrNull { it.priority } ?: 0) + 1, author = author, modId = modId) val target = folder(modsDir, mod) // No mod has this name, so a folder under it is what an interrupted import left. target.deleteRecursively() @@ -172,26 +211,72 @@ object ModLibrary { } } - /** 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") + /** The archive format [header], a file's first bytes, starts with; null for anything else. */ + fun archiveKind(header: ByteArray): ArchiveKind? { + fun startsWith(signature: ByteArray) = header.size >= signature.size && signature.indices.all { header[it] == signature[it] } + return when { + ZIP_SIGNATURES.any(::startsWith) -> ArchiveKind.Zip + startsWith(SEVEN_ZIP_SIGNATURE) -> ArchiveKind.SevenZip + startsWith(RAR_SIGNATURE) -> ArchiveKind.Rar + else -> null + } + } + + /** + * Unpacks an archive into [destination], by what it holds rather than its name, refusing + * entries that would land outside it. Every reader needs the archive as a file, so a picked + * one is copied beside the staging folder first; a downloaded one is read where it is. + */ + private fun unpack(source: Source, destination: File, progress: ModArchive.Listener?) { + val copy = if (source.file == null) File(destination.parentFile, "${destination.name}.archive") else null + val archive = source.file ?: copy!! 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) } } + if (copy != null) source.open().use { input -> copy.outputStream().use { input.copyTo(it) } } + val header = ByteArray(8) + val read = archive.inputStream().use { it.read(header) } + when (archiveKind(header.copyOf(maxOf(read, 0)))) { + ArchiveKind.Zip -> unzip(archive, source.name, destination, progress) + ArchiveKind.SevenZip, ArchiveKind.Rar -> try { + ModArchive.extract(archive.absolutePath, destination.absolutePath, progress ?: ModArchive.Listener { _, _ -> true }) + } catch (e: InterruptedIOException) { + throw e + } catch (e: IOException) { + throw IOException("${source.name}: ${e.message}", e) } + null -> throw IOException("${source.name} is not a zip, 7z or RAR archive.") } } finally { - archive.delete() + copy?.delete() + } + } + + private fun unzip(archive: File, name: String, destination: File, progress: ModArchive.Listener?) { + // ZipFile reads the central directory, which every zip has; streaming fails on some. + ZipFile(archive).use { zip -> + val entries = zip.entries().toList().filter { !it.isDirectory } + val total = entries.sumOf { maxOf(it.size, 0L) } + var done = 0L + if (progress?.update(done, total) == false) throw InterruptedIOException("Install cancelled") + for (entry in entries) { + val relative = entry.name.replace('\\', '/').trimStart('/') + if (relative.isEmpty() || relative.split('/').any { it == ".." }) { + throw IOException("$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 { output -> + val buffer = ByteArray(COPY_BUFFER) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + done += count + if (progress?.update(done, total) == false) throw InterruptedIOException("Install cancelled") + } + } + } + } } } @@ -318,10 +403,13 @@ object ModLibrary { 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. */ + /** + * True while an import runs, including a mod browser install from the start of its download + * ([ModInstaller]); imports and launch preparation take turns on one thread. + */ @Volatile var importing = false - private set + internal 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) { 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 index 74734c6..3062971 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt @@ -10,10 +10,6 @@ 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 @@ -22,15 +18,15 @@ 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]. + * The launcher's Patches page, WheelWizard's mods page (Views/Pages/ModsPage.axaml.cs): Browse + * opens the mod browser ([ModBrowser]) in place of the list, 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, and + * viewed in the browser when it came from there. What the list means for the game happens at Play, + * in [ModLibrary.prepareForLaunch]. */ class PatchesPage( private val activity: Activity, @@ -38,24 +34,45 @@ class PatchesPage( private val pickFiles: () -> Unit, ) { + private val listPage: View = root.findViewById(R.id.patches_list) + private val browserPage: View = root.findViewById(R.id.patches_browser) 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 headerButtons: View = root.findViewById(R.id.patches_header_buttons) + private val importButtons = listOf(root.findViewById(R.id.patches_import), 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() + private val browser = ModBrowser(activity, browserPage, close = { showBrowser(false) }, modsChanged = { refresh() }) + + /** Whether the browser is open in place of the list; it stays open while other pages are shown. */ + private var browsing = false + + private var installRunning = false + + private val installListener: (ModInstaller.State) -> Unit = { state -> + browser.installStateChanged() + // The list page's Import waits while a browser install holds the import slot; it only + // changes when an install starts or ends, not with every progress step. + val running = state != ModInstaller.State.Idle + if (running != installRunning && !browsing) refresh() + installRunning = running + } + init { for (button in importButtons) { button.setOnClickListener { if (!ModLibrary.importing) pickFiles() } } - styleSwitch(enableAll) + for (id in listOf(R.id.patches_browse, R.id.patches_empty_browse)) { + root.findViewById(id).setOnClickListener { showBrowser(true) } + } + PatchesWidgets.styleSwitch(activity, 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 { @@ -64,14 +81,51 @@ class PatchesPage( } } - /** Rebuilds the list from the Mods folder, which adb or the PC launcher's files may have changed. */ + /** While the launcher is in front: follows a browser install that may be running. */ + fun attach() { + installRunning = ModInstaller.busy + ModInstaller.addListener(installListener) + } + + fun detach() { + ModInstaller.removeListener(installListener) + } + + /** Back closes the browser first; false when there was nothing to close. */ + fun back(): Boolean { + if (!browsing) return false + showBrowser(false) + return true + } + + /** + * Opens the browser, on the mod with GameBanana id [modId] when one is given (View Mod), and + * for unattended tests installs that mod as soon as it has loaded when [install] is set. + */ + fun showBrowser(open: Boolean, modId: Int? = null, install: Boolean = false) { + browsing = open + listPage.visibility = if (open) View.GONE else View.VISIBLE + browserPage.visibility = if (open) View.VISIBLE else View.GONE + when { + open && modId != null -> browser.showMod(modId, install) + open -> browser.opened() + // ModBrowserWindow.BeforeClose returns to ModsPage so the list shows what was installed. + else -> refresh() + } + } + + /** Rebuilds the page from the Mods folder, which adb or the PC launcher's files may have changed. */ fun refresh() { + if (browsing) { + browser.opened() + return + } 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. + // As on the PC, the page's own Browse and Import move 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 + headerButtons.visibility = if (hasMods) View.VISIBLE else View.GONE count.text = mods.size.toString() enableAll.isChecked = mods.all { it.enabled } @@ -111,7 +165,7 @@ class PatchesPage( } // 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 -> + PatchesWidgets.nameDialog(activity, 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 @@ -135,7 +189,7 @@ class PatchesPage( val last = mods.size - 1 val toggle = Switch(activity).apply { isChecked = mod.enabled - styleSwitch(this) + PatchesWidgets.styleSwitch(activity, this) contentDescription = mod.title setOnCheckedChangeListener { _, checked -> setModEnabled(mod, checked) } } @@ -198,10 +252,14 @@ class PatchesPage( 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) + // ModsPage's View Mod, offered only for mods a browser installed: the PC's refusal + // ("Cannot view mod that was not installed through the mod browser") is all it shows otherwise. + if (mod.modId >= 0) menu.add(0, MENU_VIEW, 0, R.string.patches_view_mod) + menu.add(0, MENU_RENAME, 1, R.string.patches_rename) + menu.add(0, MENU_DELETE, 2, R.string.patches_delete) setOnMenuItemClickListener { item -> when (item.itemId) { + MENU_VIEW -> showBrowser(true, mod.modId) MENU_RENAME -> rename(mod) MENU_DELETE -> delete(mod) } @@ -237,7 +295,8 @@ class PatchesPage( } private fun rename(mod: ModLibrary.Mod) { - nameDialog( + PatchesWidgets.nameDialog( + activity, R.string.patches_rename_title, activity.getString(R.string.patches_rename_message, mod.title), mod.title, @@ -271,65 +330,6 @@ class PatchesPage( 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 -> @@ -339,21 +339,12 @@ class PatchesPage( 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 fun dp(value: Int): Int = PatchesWidgets.dp(activity, value) private companion object { const val TAG = "WiiCompiledLauncher" const val MENU_RENAME = 1 const val MENU_DELETE = 2 + const val MENU_VIEW = 3 } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesWidgets.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesWidgets.kt new file mode 100644 index 0000000..60591d3 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesWidgets.kt @@ -0,0 +1,88 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.AlertDialog +import android.content.res.ColorStateList +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.Switch +import kotlin.math.roundToInt +import org.wiicompiled.quest.R + +/** What the Patches page and its mod browser both show: the mod name dialog and the page's switches. */ +internal object PatchesWidgets { + + fun styleSwitch(activity: Activity, switch: Switch) { + switch.thumbTintList = checkedColors(activity, R.color.neutral_50, R.color.neutral_300) + switch.trackTintList = checkedColors(activity, R.color.primary_400, R.color.neutral_600) + } + + private fun checkedColors(activity: Activity, checked: Int, unchecked: Int) = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(activity.getColor(checked), activity.getColor(unchecked)), + ) + + /** + * WheelWizard's TextInputWindow: a name field that keeps the dialog open, with the reason shown, + * until the name is one [validate] accepts. + */ + fun nameDialog( + activity: Activity, + 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(activity, 22), dp(activity, 8), dp(activity, 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() + } + + fun dp(activity: Activity, value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt() +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RemoteImages.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RemoteImages.kt new file mode 100644 index 0000000..dcfccb9 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RemoteImages.kt @@ -0,0 +1,99 @@ +package org.wiicompiled.quest.launcher + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import android.util.LruCache +import android.widget.ImageView +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import org.wiicompiled.quest.BuildConfig +import org.wiicompiled.quest.R + +/** + * GameBanana's pictures for the mod browser: fetched off the main thread, scaled down to the width + * they are shown at, and kept in a memory cache so scrolling back or reopening a mod does not + * fetch them again. A picture that cannot be read is simply not shown, as in WheelWizard. + */ +object RemoteImages { + + private const val MAX_BYTES = 8 shl 20 + private const val TIMEOUT_MS = 20_000 + + private val cache = object : LruCache((Runtime.getRuntime().maxMemory() / 8).toInt()) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + private val fetchers = Executors.newFixedThreadPool(3) { runnable -> Thread(runnable, "RemoteImages").apply { isDaemon = true } } + private val main by lazy { Handler(Looper.getMainLooper()) } + + /** + * Shows the picture at [url] in [view], at most [maxWidth] pixels wide. A recycled list row + * only ever takes the picture it asked for last. + */ + fun into(view: ImageView, url: String?, maxWidth: Int) { + view.setTag(R.id.remote_image_url, url) + view.setImageDrawable(null) + if (url == null) return + fetch(url, maxWidth) { bitmap -> + if (view.getTag(R.id.remote_image_url) == url) view.setImageBitmap(bitmap) + } + } + + /** Hands the picture at [url] to [done] on the main thread; nothing happens when it cannot be read. */ + fun fetch(url: String, maxWidth: Int, done: (Bitmap) -> Unit) { + val key = "$maxWidth:$url" + cache.get(key)?.let { + done(it) + return + } + fetchers.execute { + val bitmap = try { + decode(read(url), maxWidth) + } catch (e: IOException) { + null + } catch (e: OutOfMemoryError) { + null + } ?: return@execute + cache.put(key, bitmap) + main.post { done(bitmap) } + } + } + + private fun read(url: String): ByteArray { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}") + } + try { + if (connection.responseCode != HttpURLConnection.HTTP_OK) throw IOException("HTTP ${connection.responseCode}") + connection.inputStream.use { input -> + val bytes = ByteArrayOutputStream() + val buffer = ByteArray(32 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + bytes.write(buffer, 0, count) + if (bytes.size() > MAX_BYTES) throw IOException("$url is too large to show") + } + return bytes.toByteArray() + } + } finally { + connection.disconnect() + } + } + + /** Decodes at the smallest power-of-two reduction that still covers [maxWidth]. */ + private fun decode(bytes: ByteArray, maxWidth: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / (sample * 2) >= maxWidth) sample *= 2 + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index ee25954..b91efa9 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -400,6 +400,7 @@ class SettingsPage( info(R.string.about_credit_title_graphics, activity.getString(R.string.about_credit_aurora), stacked = true) info(R.string.about_credit_title_platform, activity.getString(R.string.about_credit_openxr), stacked = true) info(R.string.about_credit_title_disc, activity.getString(R.string.about_credit_nod), stacked = true) + info(R.string.about_credit_title_mod_browser, activity.getString(R.string.about_credit_mod_browser), stacked = true) if (BuildConfig.ON_DEVICE_BUILD) { info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true) } diff --git a/android/app/src/main/res/color/warning_button_content.xml b/android/app/src/main/res/color/warning_button_content.xml new file mode 100644 index 0000000..1303648 --- /dev/null +++ b/android/app/src/main/res/color/warning_button_content.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_badge_patches.xml b/android/app/src/main/res/drawable/bg_badge_patches.xml new file mode 100644 index 0000000..449a235 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_badge_patches.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_banner_fade.xml b/android/app/src/main/res/drawable/bg_banner_fade.xml new file mode 100644 index 0000000..6bb9272 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_banner_fade.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_browser_item.xml b/android/app/src/main/res/drawable/bg_browser_item.xml new file mode 100644 index 0000000..175499c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_browser_item.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_browser_item_patches.xml b/android/app/src/main/res/drawable/bg_browser_item_patches.xml new file mode 100644 index 0000000..aff141c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_browser_item_patches.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_button_warning.xml b/android/app/src/main/res/drawable/bg_button_warning.xml new file mode 100644 index 0000000..9100800 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_button_warning.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_search_field.xml b/android/app/src/main/res/drawable/bg_search_field.xml new file mode 100644 index 0000000..166380b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_search_field.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_state_box.xml b/android/app/src/main/res/drawable/bg_state_box.xml new file mode 100644 index 0000000..e7ca754 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_state_box.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_left.xml b/android/app/src/main/res/drawable/ic_arrow_left.xml new file mode 100644 index 0000000..50e980c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_left.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_banana.xml b/android/app/src/main/res/drawable/ic_banana.xml new file mode 100644 index 0000000..e1662de --- /dev/null +++ b/android/app/src/main/res/drawable/ic_banana.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_download.xml b/android/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..049f1d0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_external_link.xml b/android/app/src/main/res/drawable/ic_external_link.xml new file mode 100644 index 0000000..d8a4867 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_external_link.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_eye.xml b/android/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000..84f579e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_heart.xml b/android/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..e8209c4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_search.xml b/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..2ce6fa0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_user.xml b/android/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..8e3e8ed --- /dev/null +++ b/android/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_warning_tip.xml b/android/app/src/main/res/drawable/ic_warning_tip.xml new file mode 100644 index 0000000..d3ef621 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_warning_tip.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/layout/item_mod_browser.xml b/android/app/src/main/res/layout/item_mod_browser.xml new file mode 100644 index 0000000..7a3352f --- /dev/null +++ b/android/app/src/main/res/layout/item_mod_browser.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_mod_browser_loading.xml b/android/app/src/main/res/layout/item_mod_browser_loading.xml new file mode 100644 index 0000000..d1514c1 --- /dev/null +++ b/android/app/src/main/res/layout/item_mod_browser_loading.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/layout/page_mod_browser.xml b/android/app/src/main/res/layout/page_mod_browser.xml new file mode 100644 index 0000000..28c1813 --- /dev/null +++ b/android/app/src/main/res/layout/page_mod_browser.xml @@ -0,0 +1,439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_patches.xml b/android/app/src/main/res/layout/page_patches.xml index e493c87..e9893dc 100644 --- a/android/app/src/main/res/layout/page_patches.xml +++ b/android/app/src/main/res/layout/page_patches.xml @@ -1,155 +1,191 @@ - - + + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingEnd="24dp"> - - - - - - - - - - - - - - - - - - - - - + android:layout_height="64dp"> + + + android:id="@+id/patches_header_buttons" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|end" + android:layout_marginBottom="10dp" + android:orientation="horizontal" + android:visibility="gone"> + android:id="@+id/patches_browse" + style="@style/Launcher.BrowseButton" /> + android:id="@+id/patches_import" + style="@style/Launcher.ImportButton" + android:layout_marginStart="8dp" /> + + - + + + + + + + + + - + android:layout_height="wrap_content" + android:layout_marginTop="18dp" + android:orientation="horizontal"> + + + + + - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:visibility="gone"> - - - - + android:layout_height="52dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index e9c6959..6a4737d 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -23,7 +23,19 @@ #03826E #08675A + #FFFEE7 + #FFFDC1 #FFC800 #D19200 + #A66802 + #FF9DAF + #FF6581 #FE2048 + + + #173726 + #1F4631 + #28503B + #1E5E3A + #D6FFE2 diff --git a/android/app/src/main/res/values/ids.xml b/android/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..36ddcbd --- /dev/null +++ b/android/app/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 358fcb4..7124f8a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -97,10 +97,12 @@ The game folder could not be prepared: %1$s + Browse 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 can alter how the game works. Browse GameBanana for your first mod, or import one with the buttons below.\n\nAs on the computer, mods change Retro Rewind. Import the mod\'s files, or the .zip, .7z or .rar it came in. + View Mod 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. @@ -128,6 +130,43 @@ Installing mods… The mods could not be installed + + Mod Browser + Back to your mods + Powered By GameBanana + Search for mods… + Search + Patches only + Uses Patches! + By: %1$s + Likes + Views + Downloads + Failed to retrieve mods. Make sure the request has at least 2 characters. + Failed to load mods.\n%1$s + No mods found for \"%1$s\". + Try again + No mod selected + Select a mod in the list to view its details + Failed to retrieve mod info + GameBanana page + Report + Images: + Description: + Download and Install + Installed + Uninstall + Cancel + Downloading %1$d%% + Installing… + Installing %1$d%% + This mod has no file to download from GameBanana. + Another mod is being installed. This one can be installed once it has finished. + Downloads %1$s (%2$s) from GameBanana and installs it as a new mod. + This mod has several files. Which one? + Mod download failed. + This headset has no browser to open the link with. + VR Graphics @@ -148,6 +187,7 @@ aurora by encounter (MIT) with Google\'s Dawn: the graphics layer every frame goes through. The OpenXR SDK by Khronos, and SDL 3 for windowing, audio and controllers. nod by encounter, which reads your disc image, built into this app as libnod_jni. + The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni. On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator. The Dolphin emulator, the reference for how the Wii behaves. The WiiCompiled logo by inkwreck. @@ -161,6 +201,7 @@ Graphics OpenXR and SDL Disc images + Mod browser On-headset builds Dolphin Logo diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 692b2ef..d9a1c3d 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -88,6 +88,76 @@ true + + + + + + + + + + + + + + + + + +