mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
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.
This commit is contained in:
1 parent
1a4b0589f4
commit
51567c7987
43 files changed
+3532
-279
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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<Preview>, 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<Image>,
|
||||
val files: List<ModFile>,
|
||||
) {
|
||||
/** 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<String>): 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<String> = (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<Image> =
|
||||
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<ModFile> = 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 <T> 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<JSONObject> = (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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<GameBanana.Preview>()
|
||||
private var shown: List<GameBanana.Preview> = 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<View>(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<View>(R.id.browser_search_button).setOnClickListener { runSearch() }
|
||||
PatchesWidgets.styleSwitch(activity, patchesOnly)
|
||||
patchesOnly.setOnClickListener { reload() }
|
||||
root.findViewById<View>(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<GameBanana.Preview> =
|
||||
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<TextView>(R.id.browser_item_title).text = mod.name
|
||||
row.findViewById<TextView>(R.id.browser_item_author).text = activity.getString(R.string.browser_by, mod.author)
|
||||
row.findViewById<TextView>(R.id.browser_item_likes).text = mod.likes.toString()
|
||||
row.findViewById<TextView>(R.id.browser_item_views).text = mod.views.toString()
|
||||
row.findViewById<View>(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<ImageView>(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 } }
|
||||
}
|
||||
}
|
||||
@@ -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<ModLibrary.Mod>?) -> 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<ModLibrary.Mod>, done: (Result<ModLibrary.Mod>?) -> 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"
|
||||
}
|
||||
@@ -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 `<name>.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 `<name>.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<Mod>): 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<Mod>, 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<Source>, mods: List<Mod>): Mod {
|
||||
fun import(
|
||||
modsDir: File,
|
||||
title: String,
|
||||
sources: List<Source>,
|
||||
mods: List<Mod>,
|
||||
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 <T> background(work: () -> T, done: (Result<T>) -> Unit) {
|
||||
|
||||
@@ -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<TextView>(R.id.patches_empty_import))
|
||||
private val headerButtons: View = root.findViewById(R.id.patches_header_buttons)
|
||||
private val importButtons = listOf(root.findViewById<TextView>(R.id.patches_import), root.findViewById<TextView>(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<ModLibrary.Mod> = 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<View>(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<View>(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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String, Bitmap>((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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_400" android:state_enabled="false" />
|
||||
<item android:color="@color/warning_50" android:state_hovered="true" />
|
||||
<item android:color="@color/warning_100" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ModBrowserListItem's "Uses Patches!" badge. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/patches_badge" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The mod's banner fades into the page under its title, as in ModContent. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:centerColor="#B324252D"
|
||||
android:endColor="@color/neutral_950"
|
||||
android:startColor="@android:color/transparent" />
|
||||
</shape>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A mod in the browser's list (ModBrowserListItem): the chosen one takes the primary outline. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<stroke android:width="1dp" android:color="@color/primary_300" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_600" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A mod that uses patches in the browser's list: ModBrowserListItem's green look. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/patches_item_hover" />
|
||||
<stroke android:width="1dp" android:color="@color/primary_300" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/patches_image" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/patches_item_hover" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/patches_item" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Warning button, used for Uninstall. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/warning_500" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/warning_600" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/warning_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The mod browser's search box. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<stroke android:width="1dp" android:color="@color/primary_400" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's StateBox, the likes, views and downloads counters. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's ArrowLeft icon (Font Awesome, 448x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.2 288 416 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0L214.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Banana icon (GameBanana's logo, drawn in a 928x928 box), filled even-odd as Avalonia does. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="56dp"
|
||||
android:height="56dp"
|
||||
android:viewportWidth="928"
|
||||
android:viewportHeight="928">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m 811.86,163.17 c -29.1,-13.4 -56.899,-15.4 -70.899,-15.4 -1.801,0 -3.5,0 -4.9,0.1 -3.2,-11.2 -19.4,-78.1 -30.7,-124.9 -5.2,-21.5 -31.1,-30.2 -48.2,-16.1 l -53.6,44.2 c -14,11.5 -16.9,31.7 -6.7,46.7 22.4,32.9 59.5,91.7 77.7,144.8 17.2,50.3 18.7,102.9 10.5,154.6 -13.4,83.7 -57.6,168.2 -131.4,251.2 -40.199,45.2 -84.699,86.399 -132.199,123.7 -23.801,18.699 -48.4,36.399 -73.7,53 -2.601,1.699 -32.7,19.399 -53.601,30.199 -11.699,6 -18.1,19 -15.8,31.9 l 2.5,14 c 2.4,13.4 13.5,23.5 27,24.6 34.3,2.9 105.101,4.801 199.7,-13.899 51.8,-10.2 101.8,-34.5 148.2,-62.101 78.1,-46.6 182.8,-131.399 238.1,-271 24.7,-62.3 35.2,-126.699 31.2,-191.599 -3.9,-63.8 -20.7,-112.4 -34.1,-142 -17.301,-38.399 -43.901,-65.899 -79.101,-82 m -164.5003,179.50028 v 5e-5 L 627.46,376.57021 c -41.7,71.199 -110.49883,133.69904 -204.59983,186.00004 -61.2,34.1 -126.8003,60.09867 -193.6003,81.19767 -36.4,11.5 -71.60024,21.60293 -108.60024,27.30293 -14.6,2.199 -25.298683,14.7993 -25.298683,29.5993 v 18.89872 c 0,10.3 5.299873,19.9006 14.099873,25.4006 13.1,8.101 34.39924,19.80129 60.29924,28.40129 44.5,14.8 91.80075,21.19884 138.50075,22.69884 l 2.59984,0.0996 2.09936,-1.39957 c 34.5,-23.1 67.30071,-47.29991 97.60071,-71.89891 37.7,-30.7 71.49949,-62.2003 100.49949,-93.6013 26.3,-28.5 50.60069,-58.89832 71.40069,-91.59932 18.199,-28.6 33.6988,-59.10074 44.7998,-91.20074 5.2,-15 9.5001,-30.39873 12.3991,-46.09973 3,-15.9 4.39962,-32.09908 6.49962,-48.19908 0.4,-3.2 0.80128,-6.30032 1.20028,-9.50032 l -26.20003,-53.39873 -15.99993,35.39914 c -33.6,74.299 -98.50016,139.49959 -193.00016,193.79959 -69.6,40 -154.20005,73.60071 -251.20005,99.79971 l -2.40055,0.69979 -2.49794,-0.79943 c -73.600001,-23.2 -129.600574,-71.69962 -129.600574,-71.69962 0,0 -7.499299,-6.50079 -14.899299,-13.20079 -8.8999996,-8.1 -12.10038887,-20.69887 -8.2003889,-31.99987 l 6.5992638,-19.098 c 4.4000001,-12.6 16.5008621,-20.80091 29.8008621,-20.20091 l 16.899007,0.79943 c 7.2,0.1 19.201168,0.20156 24.601168,0.20156 103.800001,0 197.900631,-10.70093 279.800631,-31.70093 96.599,-24.8 174.39999,-63.69964 231.39999,-115.79964 l 28.69797,-26.20003" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Download icon (Font Awesome, 384x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="14dp"
|
||||
android:height="14dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's ExternalLink icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0L201.4 265.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3l0 82.7c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 32C35.8 32 0 67.8 0 112L0 432c0 44.2 35.8 80 80 80l320 0c44.2 0 80-35.8 80-80l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 112c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-320c0-8.8 7.2-16 16-16l112 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 32z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Eye icon (Font Awesome, 576x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="14dp"
|
||||
android:height="14dp"
|
||||
android:viewportWidth="576"
|
||||
android:viewportHeight="576">
|
||||
<group android:translateY="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Heart icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="14dp"
|
||||
android:height="14dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Search icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's User icon (Font Awesome, 448x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l388.6 0c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304l-91.4 0z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's WarningTip icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One mod in the browser's list: WheelWizard's ModBrowserListItem, its picture beside the title,
|
||||
author and counts. ModBrowser gives mods that use patches the green look and the badge. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="84dp"
|
||||
android:background="@drawable/bg_browser_item"
|
||||
android:clipToOutline="true"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/browser_item_image"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/neutral_700"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_item_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:maxLines="2"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_item_author"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_item_patches"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_badge_patches"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingTop="1dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingBottom="1dp"
|
||||
android:singleLine="true"
|
||||
android:text="@string/browser_uses_patches"
|
||||
android:textColor="@color/patches_badge_text"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_item_likes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/browser_likes"
|
||||
android:drawableStart="@drawable/ic_heart"
|
||||
android:drawablePadding="4dp"
|
||||
android:drawableTint="@color/neutral_500"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_item_views"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:contentDescription="@string/browser_views"
|
||||
android:drawableStart="@drawable/ic_eye"
|
||||
android:drawablePadding="4dp"
|
||||
android:drawableTint="@color/neutral_500"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The row at the end of the browser's list while more results are on their way. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminateTint="@color/neutral_600" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,439 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's mod browser (Views/Popups/ModManagement/ModBrowserWindow.axaml with ModContent),
|
||||
opened from the Patches page: GameBanana's Mario Kart Wii mods on the left, the chosen one on
|
||||
the right. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/browser_back"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="-10dp"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/browser_back"
|
||||
android:focusable="true"
|
||||
android:padding="10dp"
|
||||
android:src="@drawable/ic_arrow_left"
|
||||
android:tint="@color/neutral_300" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/browser_title" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:text="@string/browser_powered_by"
|
||||
android:textColor="@color/neutral_500"
|
||||
android:textSize="12sp" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- The list: search, Patches only, results. -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="42dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/browser_search"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_search_field"
|
||||
android:hint="@string/browser_search_hint"
|
||||
android:imeOptions="actionSearch"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="text"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textColorHint="@color/neutral_500"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/browser_search_button"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_button_secondary"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/browser_search"
|
||||
android:focusable="true"
|
||||
android:padding="12dp"
|
||||
android:src="@drawable/ic_search"
|
||||
android:tint="@color/neutral_100" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/browser_patches_only_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="44dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Switch
|
||||
android:id="@+id/browser_patches_only"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/browser_patches_only"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/browser_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:choiceMode="singleChoice"
|
||||
android:clipToPadding="false"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="6dp"
|
||||
android:fadeScrollbars="false"
|
||||
android:listSelector="@android:color/transparent"
|
||||
android:paddingBottom="16dp"
|
||||
android:scrollbarStyle="outsideOverlay" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/browser_results_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_results_message_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_retry"
|
||||
style="@style/Launcher.Button.Secondary"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/browser_retry" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<!-- The chosen mod. -->
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/browser_details_loading"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminateTint="@color/neutral_600"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/browser_details_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_banana"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_details_empty_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:gravity="center"
|
||||
android:text="@string/browser_no_mod_selected_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_details_empty_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="360dp"
|
||||
android:text="@string/browser_no_mod_selected"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/browser_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="128dp"
|
||||
android:background="@drawable/bg_row_single"
|
||||
android:clipToOutline="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/browser_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_banner_fade" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:maxLines="2"
|
||||
android:shadowColor="#CC000000"
|
||||
android:shadowRadius="6"
|
||||
android:textColor="@color/neutral_50"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_likes"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/browser_likes"
|
||||
android:drawableStart="@drawable/ic_heart" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_views"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginStart="4dp"
|
||||
android:contentDescription="@string/browser_views"
|
||||
android:drawableStart="@drawable/ic_eye" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_downloads"
|
||||
style="@style/Launcher.StateBox"
|
||||
android:layout_marginStart="4dp"
|
||||
android:contentDescription="@string/browser_downloads"
|
||||
android:drawableStart="@drawable/ic_download" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_author"
|
||||
style="@style/Launcher.LinkButton"
|
||||
android:layout_marginStart="-6dp"
|
||||
android:drawableStart="@drawable/ic_user"
|
||||
android:maxWidth="200dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_page"
|
||||
style="@style/Launcher.LinkButton"
|
||||
android:layout_marginStart="4dp"
|
||||
android:drawableEnd="@drawable/ic_external_link"
|
||||
android:text="@string/browser_gamebanana_page" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_report"
|
||||
style="@style/Launcher.LinkButton"
|
||||
android:drawableEnd="@drawable/ic_warning_tip"
|
||||
android:drawableTint="@color/danger_400"
|
||||
android:text="@string/browser_report"
|
||||
android:textColor="@color/danger_400" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_install"
|
||||
style="@style/Launcher.Button.Primary"
|
||||
android:minWidth="190dp"
|
||||
android:text="@string/browser_install" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_cancel"
|
||||
style="@style/Launcher.Button.Secondary"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="@string/browser_cancel"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_uninstall"
|
||||
style="@style/Launcher.Button.Warning"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="@string/browser_uninstall"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_install_note"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/browser_details_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_row_single"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbarStyle="insideOverlay">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_images_label"
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/browser_images" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/browser_images_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:scrollbarStyle="insideOverlay">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/browser_images"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="140dp"
|
||||
android:orientation="horizontal" />
|
||||
</HorizontalScrollView>
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/browser_description" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/browser_mod_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingMultiplier="1.15"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textColorLink="@color/primary_300"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,155 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Patches page (Views/Pages/ModsPage.axaml) without its mod browser. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<!-- WheelWizard's Patches page (Views/Pages/ModsPage.axaml), with its mod browser
|
||||
(page_mod_browser.xml) opened in place of the list. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/launcher_nav_patches" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_patches"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/patches_empty_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="440dp"
|
||||
android:text="@string/patches_empty_message"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_empty_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_marginTop="18dp"
|
||||
android:minWidth="190dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_content"
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
android:layout_height="64dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/launcher_nav_patches" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
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">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/patches_mods" />
|
||||
android:id="@+id/patches_browse"
|
||||
style="@style/Launcher.BrowseButton" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="13sp" />
|
||||
android:id="@+id/patches_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_patches"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_enable_all_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/patches_enable_all"
|
||||
android:layout_marginTop="14dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/patches_empty_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="440dp"
|
||||
android:text="@string/patches_empty_message"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/patches_enable_all"
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_empty_browse"
|
||||
style="@style/Launcher.BrowseButton"
|
||||
android:minWidth="150dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_empty_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:minWidth="150dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbarStyle="outsideOverlay">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_rows"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="28dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
android:layout_height="52dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/patches_mods" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_enable_all_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/patches_enable_all"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/patches_enable_all"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbarStyle="outsideOverlay">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_rows"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="28dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<include
|
||||
android:id="@+id/patches_browser"
|
||||
layout="@layout/page_mod_browser"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
@@ -23,7 +23,19 @@
|
||||
<color name="primary_700">#03826E</color>
|
||||
<color name="primary_800">#08675A</color>
|
||||
|
||||
<color name="warning_50">#FFFEE7</color>
|
||||
<color name="warning_100">#FFFDC1</color>
|
||||
<color name="warning_500">#FFC800</color>
|
||||
<color name="warning_600">#D19200</color>
|
||||
<color name="warning_700">#A66802</color>
|
||||
<color name="danger_300">#FF9DAF</color>
|
||||
<color name="danger_400">#FF6581</color>
|
||||
<color name="danger_500">#FE2048</color>
|
||||
|
||||
<!-- The mod browser's look for mods that use patches (Views/Patterns/ModBrowserListItem.axaml). -->
|
||||
<color name="patches_item">#173726</color>
|
||||
<color name="patches_item_hover">#1F4631</color>
|
||||
<color name="patches_image">#28503B</color>
|
||||
<color name="patches_badge">#1E5E3A</color>
|
||||
<color name="patches_badge_text">#D6FFE2</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- The picture URL an ImageView last asked RemoteImages for. -->
|
||||
<item name="remote_image_url" type="id" />
|
||||
</resources>
|
||||
@@ -97,10 +97,12 @@
|
||||
<string name="game_storage_failed">The game folder could not be prepared: %1$s</string>
|
||||
|
||||
<!-- Patches: WheelWizard's mods page, its wording from the PC launcher's en.yml -->
|
||||
<string name="patches_browse">Browse</string>
|
||||
<string name="patches_import">Import</string>
|
||||
<string name="patches_importing">Importing…</string>
|
||||
<string name="patches_empty_title">No mods found</string>
|
||||
<string name="patches_empty_message">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.</string>
|
||||
<string name="patches_empty_message">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.</string>
|
||||
<string name="patches_view_mod">View Mod</string>
|
||||
<string name="patches_mods">Mods</string>
|
||||
<string name="patches_enable_all">Enable all</string>
|
||||
<string name="patches_note">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.</string>
|
||||
@@ -128,6 +130,43 @@
|
||||
<string name="home_installing_patches">Installing mods…</string>
|
||||
<string name="home_patches_failed">The mods could not be installed</string>
|
||||
|
||||
<!-- The mod browser: WheelWizard's ModBrowserWindow and ModContent, wording from en.yml -->
|
||||
<string name="browser_title">Mod Browser</string>
|
||||
<string name="browser_back">Back to your mods</string>
|
||||
<string name="browser_powered_by">Powered By GameBanana</string>
|
||||
<string name="browser_search_hint">Search for mods…</string>
|
||||
<string name="browser_search">Search</string>
|
||||
<string name="browser_patches_only">Patches only</string>
|
||||
<string name="browser_uses_patches">Uses Patches!</string>
|
||||
<string name="browser_by">By: %1$s</string>
|
||||
<string name="browser_likes">Likes</string>
|
||||
<string name="browser_views">Views</string>
|
||||
<string name="browser_downloads">Downloads</string>
|
||||
<string name="browser_search_too_short">Failed to retrieve mods. Make sure the request has at least 2 characters.</string>
|
||||
<string name="browser_load_failed">Failed to load mods.\n%1$s</string>
|
||||
<string name="browser_no_results">No mods found for \"%1$s\".</string>
|
||||
<string name="browser_retry">Try again</string>
|
||||
<string name="browser_no_mod_selected_title">No mod selected</string>
|
||||
<string name="browser_no_mod_selected">Select a mod in the list to view its details</string>
|
||||
<string name="browser_details_failed">Failed to retrieve mod info</string>
|
||||
<string name="browser_gamebanana_page">GameBanana page</string>
|
||||
<string name="browser_report">Report</string>
|
||||
<string name="browser_images">Images:</string>
|
||||
<string name="browser_description">Description:</string>
|
||||
<string name="browser_install">Download and Install</string>
|
||||
<string name="browser_installed">Installed</string>
|
||||
<string name="browser_uninstall">Uninstall</string>
|
||||
<string name="browser_cancel">Cancel</string>
|
||||
<string name="browser_downloading">Downloading %1$d%%</string>
|
||||
<string name="browser_installing">Installing…</string>
|
||||
<string name="browser_installing_percent">Installing %1$d%%</string>
|
||||
<string name="browser_no_files">This mod has no file to download from GameBanana.</string>
|
||||
<string name="browser_busy">Another mod is being installed. This one can be installed once it has finished.</string>
|
||||
<string name="browser_install_note">Downloads %1$s (%2$s) from GameBanana and installs it as a new mod.</string>
|
||||
<string name="browser_choose_file">This mod has several files. Which one?</string>
|
||||
<string name="browser_install_failed">Mod download failed.</string>
|
||||
<string name="browser_no_browser">This headset has no browser to open the link with.</string>
|
||||
|
||||
<!-- Settings shell -->
|
||||
<string name="settings_tab_vr">VR</string>
|
||||
<string name="settings_tab_graphics">Graphics</string>
|
||||
@@ -148,6 +187,7 @@
|
||||
<string name="about_credit_aurora">aurora by encounter (MIT) with Google\'s Dawn: the graphics layer every frame goes through.</string>
|
||||
<string name="about_credit_openxr">The OpenXR SDK by Khronos, and SDL 3 for windowing, audio and controllers.</string>
|
||||
<string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string>
|
||||
<string name="about_credit_mod_browser">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.</string>
|
||||
<string name="about_credit_toolchain">On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator.</string>
|
||||
<string name="about_credit_dolphin">The Dolphin emulator, the reference for how the Wii behaves.</string>
|
||||
<string name="about_credit_logo">The WiiCompiled logo by inkwreck.</string>
|
||||
@@ -161,6 +201,7 @@
|
||||
<string name="about_credit_title_graphics">Graphics</string>
|
||||
<string name="about_credit_title_platform">OpenXR and SDL</string>
|
||||
<string name="about_credit_title_disc">Disc images</string>
|
||||
<string name="about_credit_title_mod_browser">Mod browser</string>
|
||||
<string name="about_credit_title_toolchain">On-headset builds</string>
|
||||
<string name="about_credit_title_dolphin">Dolphin</string>
|
||||
<string name="about_credit_title_logo">Logo</string>
|
||||
|
||||
@@ -88,6 +88,76 @@
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<!-- The Patches page's Browse, WheelWizard's Primary button with its Search icon. -->
|
||||
<style name="Launcher.BrowseButton" parent="Launcher.ImportButton">
|
||||
<item name="android:background">@drawable/bg_play_button</item>
|
||||
<item name="android:drawableStart">@drawable/ic_search</item>
|
||||
<item name="android:drawableTint">@color/play_button_content</item>
|
||||
<item name="android:text">@string/patches_browse</item>
|
||||
<item name="android:textColor">@color/play_button_content</item>
|
||||
</style>
|
||||
|
||||
<!-- The mod browser's buttons: WheelWizard's Primary, Default and Warning variants. -->
|
||||
<style name="Launcher.Button">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">40dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:minWidth">96dp</item>
|
||||
<item name="android:paddingStart">16dp</item>
|
||||
<item name="android:paddingEnd">16dp</item>
|
||||
<item name="android:textSize">15sp</item>
|
||||
<item name="android:singleLine">true</item>
|
||||
<item name="android:clickable">true</item>
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Button.Primary">
|
||||
<item name="android:background">@drawable/bg_play_button</item>
|
||||
<item name="android:textColor">@color/play_button_content</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Button.Secondary">
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:textColor">@color/neutral_100</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Button.Warning">
|
||||
<item name="android:background">@drawable/bg_button_warning</item>
|
||||
<item name="android:textColor">@color/warning_button_content</item>
|
||||
</style>
|
||||
|
||||
<!-- WheelWizard's IconLabelButton: a coloured link with an icon, such as the mod's author. -->
|
||||
<style name="Launcher.LinkButton">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">32dp</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
<item name="android:paddingStart">6dp</item>
|
||||
<item name="android:paddingEnd">6dp</item>
|
||||
<item name="android:background">@drawable/bg_nav_item</item>
|
||||
<item name="android:drawablePadding">6dp</item>
|
||||
<item name="android:drawableTint">@color/primary_300</item>
|
||||
<item name="android:textColor">@color/primary_300</item>
|
||||
<item name="android:textSize">13sp</item>
|
||||
<item name="android:singleLine">true</item>
|
||||
<item name="android:ellipsize">end</item>
|
||||
<item name="android:clickable">true</item>
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<!-- WheelWizard's StateBox: a count with its icon. -->
|
||||
<style name="Launcher.StateBox">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">26dp</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
<item name="android:paddingStart">8dp</item>
|
||||
<item name="android:paddingEnd">8dp</item>
|
||||
<item name="android:background">@drawable/bg_state_box</item>
|
||||
<item name="android:drawablePadding">5dp</item>
|
||||
<item name="android:drawableTint">@color/neutral_500</item>
|
||||
<item name="android:textColor">@color/neutral_100</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Tab">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">44dp</item>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** The mod browser reads GameBanana's answers the way WheelWizard's does; shapes taken from the live API. */
|
||||
class GameBananaTest {
|
||||
|
||||
private val page = """
|
||||
{"_aMetadata": {"_nRecordCount": 999, "_nPerpage": 15, "_bIsComplete": false},
|
||||
"_aRecords": [
|
||||
{"_idRow": 699980, "_sModelName": "Mod", "_sName": "MKWII Retro Rewind - Vanellope",
|
||||
"_aTags": ["Mod: Retro Rewind", "Character: Vanellope"], "_bHasContentRatings": false,
|
||||
"_nLikeCount": 4, "_nViewCount": 2524,
|
||||
"_aSubmitter": {"_sName": "toothcakee", "_sProfileUrl": "https://gamebanana.com/members/5730856"},
|
||||
"_aPreviewMedia": {"_aImages": [
|
||||
{"_sType": "screenshot", "_sBaseUrl": "https://images.gamebanana.com/img/ss/mods", "_sFile": "a.jpg",
|
||||
"_sFile220": "220-90_a.jpg", "_sFile530": "530-90_a.jpg"},
|
||||
{"_sType": "screenshot", "_sBaseUrl": "https://images.gamebanana.com/img/ss/mods", "_sFile": "b.jpg"}]}},
|
||||
{"_idRow": 12, "_sModelName": "Mod", "_sName": "Different Menu Style", "_aTags": ["Patches: True"],
|
||||
"_bHasContentRatings": false, "_nLikeCount": 1, "_nViewCount": 10, "_aSubmitter": {"_sName": "someone"}},
|
||||
{"_idRow": 13, "_sModelName": "Mod", "_sName": "Rated", "_aTags": [], "_bHasContentRatings": true},
|
||||
{"_idRow": 14, "_sModelName": "Question", "_sName": "How do I install mods?", "_aTags": []},
|
||||
{"_idRow": 15, "_sModelName": "Mod", "_sName": "No pictures", "_aTags": null, "_aSubmitter": null,
|
||||
"_aPreviewMedia": {}}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
private val details = """
|
||||
{"_idRow": 596169, "_sName": "Sonic 06 Dark Rider", "_nLikeCount": 7, "_nViewCount": 900,
|
||||
"_nDownloadCount": 321, "_sProfileUrl": "https://gamebanana.com/mods/596169",
|
||||
"_sText": "A <b>bike</b><br><span class=\"GreenColor\">green</span>",
|
||||
"_aSubmitter": {"_sName": "rider", "_sProfileUrl": "https://gamebanana.com/members/1"},
|
||||
"_aTags": [{"_sTitle": "Patches", "_sValue": "True"}],
|
||||
"_aFiles": [
|
||||
{"_sFile": "shadow_bike_sonic006.zip", "_nFilesize": 214000, "_sDownloadUrl": "https://gamebanana.com/dl/1",
|
||||
"_sMd5Checksum": "f58e50d011bac0d1d57809340cb2810a", "_sDescription": "OLD"},
|
||||
{"_sFile": "shadow_bike_sonic06.zip", "_nFilesize": 214385, "_sDownloadUrl": "https://gamebanana.com/dl/2",
|
||||
"_sMd5Checksum": null, "_sDescription": null}],
|
||||
"_aArchivedFiles": [
|
||||
{"_sFile": "older.zip", "_nFilesize": 5, "_sDownloadUrl": "https://gamebanana.com/dl/0"}]}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun searchPagesListOnlyMods() {
|
||||
val parsed = GameBanana.parsePage(page)
|
||||
// Questions and rated mods are left out, as WheelWizard's browser leaves them out.
|
||||
assertEquals(listOf(699980, 12, 15), parsed.mods.map { it.id })
|
||||
assertFalse(parsed.complete)
|
||||
val first = parsed.mods[0]
|
||||
assertEquals("MKWII Retro Rewind - Vanellope", first.name)
|
||||
assertEquals("toothcakee", first.author)
|
||||
assertEquals(4L, first.likes)
|
||||
assertEquals(2524L, first.views)
|
||||
assertFalse(first.usesPatches)
|
||||
assertEquals("https://images.gamebanana.com/img/ss/mods/220-90_a.jpg", first.image?.small)
|
||||
assertEquals("https://images.gamebanana.com/img/ss/mods/530-90_a.jpg", first.image?.medium)
|
||||
assertTrue(parsed.mods[1].usesPatches)
|
||||
// JSON nulls and empty objects read as nothing rather than as the text "null".
|
||||
assertEquals("", parsed.mods[2].author)
|
||||
assertNull(parsed.mods[2].image)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLastPageSaysSo() {
|
||||
assertTrue(GameBanana.parsePage("""{"_aMetadata": {"_bIsComplete": true}, "_aRecords": []}""").complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailsCarryTheFilesToDownload() {
|
||||
val mod = GameBanana.parseDetails(details)
|
||||
assertEquals(596169, mod.id)
|
||||
assertEquals("rider", mod.author)
|
||||
assertEquals("https://gamebanana.com/members/1", mod.authorUrl)
|
||||
assertEquals(321L, mod.downloads)
|
||||
assertEquals("https://gamebanana.com/support/add?s=Mod.596169", mod.reportUrl)
|
||||
assertEquals(listOf("shadow_bike_sonic006.zip", "shadow_bike_sonic06.zip"), mod.files.map { it.name })
|
||||
assertEquals("f58e50d011bac0d1d57809340cb2810a", mod.files[0].md5)
|
||||
assertEquals("OLD", mod.files[0].description)
|
||||
assertNull(mod.files[1].md5)
|
||||
assertEquals("", mod.files[1].description)
|
||||
assertEquals(214385L, mod.files[1].size)
|
||||
assertTrue(mod.images.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivedFilesStandInWhenThereAreNoOthers() {
|
||||
val mod = GameBanana.parseDetails("""{"_idRow": 1, "_sName": "Old", "_aFiles": [], "_aArchivedFiles": [{"_sFile": "old.rar", "_sDownloadUrl": "u"}]}""")
|
||||
assertEquals(listOf("old.rar"), mod.files.map { it.name })
|
||||
// Mods whose files live elsewhere (Google Drive, Mega) have nothing to download here.
|
||||
assertTrue(GameBanana.parseDetails("""{"_idRow": 2, "_sName": "Elsewhere"}""").files.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privateModsSayWhyTheyCannotBeShown() {
|
||||
// What GameBanana answers for a private mod a search still lists (N64 Mod Pack, 451155).
|
||||
val stub = """{"_idRow": 451155, "_nStatus": "2", "_bIsPrivate": true, "_bAccessorIsSubmitter": false, "_bIsTrashed": false, "_bIsWithheld": false, "_sName": "N64 Mod Pack"}"""
|
||||
try {
|
||||
GameBanana.parseDetails(stub)
|
||||
fail("showed a private mod")
|
||||
} catch (expected: IOException) {
|
||||
assertTrue(expected.message!!, expected.message!!.contains("private"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchesTagsAreTheOnesTitledPatchOrPatches() {
|
||||
assertTrue(GameBanana.usesPatches(listOf("Patches: True")))
|
||||
assertTrue(GameBanana.usesPatches(listOf(" patch ")))
|
||||
assertTrue(GameBanana.usesPatches(listOf("Patches")))
|
||||
assertFalse(GameBanana.usesPatches(listOf("Mod: Retro Rewind", "Software Used: Patch that BRSAR!")))
|
||||
assertFalse(GameBanana.usesPatches(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySearchesStillListSomething() {
|
||||
assertEquals("Mod", GameBanana.effectiveSearch(" ", patchesOnly = false))
|
||||
assertEquals("Patches", GameBanana.effectiveSearch("", patchesOnly = true))
|
||||
assertEquals("Luigi", GameBanana.effectiveSearch(" Luigi ", patchesOnly = true))
|
||||
assertEquals(
|
||||
"https://gamebanana.com/apiv12/Util/Search/Results?_sSearchString=Mario%20%26%20Luigi&_idGameRow=5896&_sModelName=Mod&_nPage=2",
|
||||
GameBanana.searchUrl("Mario & Luigi", 2),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusalsExplainThemselves() {
|
||||
val tooShort = """{"_sErrorCode": "INPUT_ERRORS", "_aErrorData": {"_sSearchString": {"_sErrorCode": "STRING_TOO_SHORT", "_sErrorMessage": "Must be 2 characters or more"}}}"""
|
||||
assertEquals("Must be 2 characters or more", GameBanana.errorMessage(tooShort))
|
||||
assertEquals("NOT_FOUND", GameBanana.errorMessage("""{"_sErrorCode": "NOT_FOUND"}"""))
|
||||
assertNull(GameBanana.errorMessage("<html>Bad gateway</html>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableAnswersAreIoErrors() {
|
||||
for (json in listOf("<html>", """{"_aMetadata": {}}""", """{"_idRow": 3}""")) {
|
||||
try {
|
||||
if (json.contains("_idRow")) GameBanana.parseDetails(json) else GameBanana.parsePage(json)
|
||||
fail("parsed $json")
|
||||
} catch (expected: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.wiicompiled.quest.launcher
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
@@ -150,4 +151,85 @@ class ModLibraryTest {
|
||||
assertFalse(patches.exists())
|
||||
assertFalse(ModLibrary.shouldAskToClear(disabled, patches))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivesAreToldApartByTheirFirstBytes() {
|
||||
fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||
assertEquals(ModLibrary.ArchiveKind.Zip, ModLibrary.archiveKind(bytes(0x50, 0x4B, 0x03, 0x04, 0x14)))
|
||||
assertEquals(ModLibrary.ArchiveKind.Zip, ModLibrary.archiveKind(bytes(0x50, 0x4B, 0x05, 0x06)))
|
||||
assertEquals(ModLibrary.ArchiveKind.SevenZip, ModLibrary.archiveKind(bytes(0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04)))
|
||||
// RAR 1.5 to 4.x, then RAR5.
|
||||
assertEquals(ModLibrary.ArchiveKind.Rar, ModLibrary.archiveKind(bytes(0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00)))
|
||||
assertEquals(ModLibrary.ArchiveKind.Rar, ModLibrary.archiveKind(bytes(0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00)))
|
||||
assertNull(ModLibrary.archiveKind(bytes(0x52, 0x61, 0x72)))
|
||||
assertNull(ModLibrary.archiveKind("text".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun browserInstallsRememberTheirGameBananaMod() {
|
||||
val modsDir = temp()
|
||||
val archive = File(temp(), "vanellope.zip")
|
||||
archive.outputStream().use { out ->
|
||||
ZipOutputStream(out).use { zip ->
|
||||
zip.putNextEntry(ZipEntry("Vanellope/Patches/Driver.szs"))
|
||||
zip.write("driver".toByteArray())
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
// A downloaded archive is read where it is, not opened as a stream and copied again.
|
||||
val downloaded = ModLibrary.Source(archive.name, archive) { throw IOException("copied a downloaded archive") }
|
||||
val mod = ModLibrary.import(modsDir, "Vanellope", listOf(downloaded), emptyList(), author = "toothcakee", modId = 699980)
|
||||
assertEquals(ModLibrary.Mod("Vanellope", enabled = true, priority = 1, author = "toothcakee", modId = 699980), mod)
|
||||
assertEquals(listOf(mod), ModLibrary.load(modsDir))
|
||||
assertEquals("driver", File(modsDir, "Vanellope/Vanellope/Patches/Driver.szs").readText())
|
||||
assertTrue(archive.isFile)
|
||||
assertEquals(mod, ModLibrary.installed(ModLibrary.load(modsDir), 699980))
|
||||
assertNull(ModLibrary.installed(ModLibrary.load(modsDir), 1))
|
||||
// Imported mods have no GameBanana id, and -1 never means installed.
|
||||
assertNull(ModLibrary.installed(listOf(ModLibrary.Mod("Local", true, 1)), -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zipProgressCountsTheBytesAndCanCancel() {
|
||||
val modsDir = temp()
|
||||
val updates = mutableListOf<Pair<Long, Long>>()
|
||||
val counting = ModArchive.Listener { done, total ->
|
||||
updates += done to total
|
||||
true
|
||||
}
|
||||
ModLibrary.import(modsDir, "Two", listOf(zip("two.zip", "a.szs" to "aaaa", "b.szs" to "bb")), emptyList(), progress = counting)
|
||||
assertEquals(0L to 6L, updates.first())
|
||||
assertEquals(6L to 6L, updates.last())
|
||||
|
||||
try {
|
||||
val stop = ModArchive.Listener { _, _ -> false }
|
||||
ModLibrary.import(modsDir, "Stopped", listOf(zip("stop.zip", "a.szs" to "aaaa")), ModLibrary.load(modsDir), progress = stop)
|
||||
fail("a cancelled import finished")
|
||||
} catch (expected: InterruptedIOException) {
|
||||
}
|
||||
assertEquals(setOf("Two"), modsDir.list()!!.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivesThatAreNotWhatTheyAreCalledAreRefused() {
|
||||
val modsDir = temp()
|
||||
for (name in listOf("mod.7z", "mod.rar", "mod.zip")) {
|
||||
try {
|
||||
ModLibrary.import(modsDir, "Fake", listOf(source(name, "not an archive")), emptyList())
|
||||
fail("imported $name")
|
||||
} catch (expected: IOException) {
|
||||
assertTrue(expected.message!!, expected.message!!.contains("not a zip, 7z or RAR archive"))
|
||||
}
|
||||
}
|
||||
assertEquals(0, modsDir.list()!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gameBananaNamesBecomeFolderNames() {
|
||||
assertEquals("PaRappa Award szs for CTGP", ModLibrary.nameFrom("PaRappa Award.szs for CTGP"))
|
||||
assertEquals("Mario Kart Wii", ModLibrary.nameFrom("Mario Kart: Wii"))
|
||||
assertEquals("Galacta Character Mod MKWii", ModLibrary.nameFrom("Galacta Character Mod | MKWii"))
|
||||
assertEquals("Retro Rewind - Vanellope", ModLibrary.nameFrom(" Retro Rewind - Vanellope "))
|
||||
assertNull(ModLibrary.validateName(ModLibrary.nameFrom("a/b\\c?.d"), emptyList()))
|
||||
}
|
||||
}
|
||||
Generated
+305
@@ -17,6 +17,16 @@ dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -55,12 +65,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
@@ -114,6 +139,12 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -124,6 +155,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
@@ -157,6 +194,25 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -172,6 +228,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
@@ -179,7 +244,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -215,12 +282,47 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
@@ -228,8 +330,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -254,6 +358,15 @@ dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -336,6 +449,23 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -380,6 +510,12 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
|
||||
|
||||
[[package]]
|
||||
name = "macro_rules_attribute"
|
||||
version = "0.2.3"
|
||||
@@ -412,6 +548,16 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "never-say-never"
|
||||
version = "6.6.666"
|
||||
@@ -458,6 +604,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"nod",
|
||||
"rars",
|
||||
"sevenz-rust2",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
@@ -510,6 +658,12 @@ dependencies = [
|
||||
"never-say-never",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -534,6 +688,48 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rars"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d61643d05cb7e5045cc4f3e066ecf4b4e7ac394b9a052067906f530e91d3c88"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aho-corasick",
|
||||
"getrandom",
|
||||
"hmac",
|
||||
"rayon",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -543,6 +739,21 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sevenz-rust2"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a4f883677093690e91fef8ae81fad8bce9e2c3a079b61054f05ce0d3ecf681e"
|
||||
dependencies = [
|
||||
"bzip2",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"js-sys",
|
||||
"lzma-rust2",
|
||||
"ppmd-rust",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -554,12 +765,29 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simple_moving_average"
|
||||
version = "1.0.2"
|
||||
@@ -569,6 +797,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -690,6 +924,51 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -806,6 +1085,32 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
name = "nod-jni"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
# sevenz-rust2 0.23 needs 1.93.
|
||||
rust-version = "1.93"
|
||||
license = "GPL-3.0-only"
|
||||
publish = false
|
||||
description = "JNI bridge from the Quest launcher to nod, the disc image library behind the PC installer's nodtool."
|
||||
description = "JNI bridge from the Quest launcher to nod, the disc image library behind the PC installer's nodtool, and to the 7z and RAR readers its Patches page installs mods with."
|
||||
|
||||
[lib]
|
||||
name = "nod_jni"
|
||||
@@ -26,6 +27,10 @@ nod = { git = "https://github.com/encounter/nod", tag = "v2.0.0-alpha.10", defau
|
||||
"threading",
|
||||
] }
|
||||
zerocopy = "0.8"
|
||||
# Mod archives GameBanana serves besides .zip (src/archive.rs), both pure Rust. The 7z codecs are
|
||||
# the ones WheelWizard's SharpCompress reads; AES is left out, as there is no password to give.
|
||||
sevenz-rust2 = { version = "0.23", default-features = false, features = ["bzip2", "deflate", "ppmd"] }
|
||||
rars = "0.9.4"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Mod archives for the launcher's Patches page (`org.wiicompiled.quest.launcher.ModArchive`).
|
||||
//!
|
||||
//! WheelWizard unpacks every mod it installs with SharpCompress, which reads .zip, .7z and .rar,
|
||||
//! and GameBanana serves all three. Android's java.util.zip covers .zip; this module covers the
|
||||
//! other two in pure Rust: sevenz-rust2 for 7z and rars for RAR (1.5 to 4.x, and RAR5 onwards).
|
||||
//! Both decoders check each file's stored CRC or hash, so a damaged download fails instead of
|
||||
//! installing broken files.
|
||||
//!
|
||||
//! Only regular files are written, and only below the destination: a name that climbs out with
|
||||
//! `..` fails the whole archive, as in the launcher's own zip reader. Directories and links write
|
||||
//! nothing.
|
||||
|
||||
use std::{
|
||||
cell::Cell,
|
||||
fs::{self, File},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use sevenz_rust2::{ArchiveEntry, ArchiveReader, Password};
|
||||
|
||||
pub enum Failure {
|
||||
Error(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl<E: std::fmt::Display> From<E> for Failure {
|
||||
fn from(error: E) -> Self { Failure::Error(error.to_string()) }
|
||||
}
|
||||
|
||||
type Outcome<T> = Result<T, Failure>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
SevenZip,
|
||||
Rar,
|
||||
}
|
||||
|
||||
const SEVEN_ZIP_SIGNATURE: &[u8] = &[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C];
|
||||
/// Followed by 0x00 for RAR 1.5 to 4.x and by 0x01 0x00 for RAR5.
|
||||
const RAR_SIGNATURE: &[u8] = b"Rar!\x1a\x07";
|
||||
|
||||
/// Unix file type bits 7-Zip keeps in the high half of the attributes when 0x8000 is set.
|
||||
const SEVEN_ZIP_UNIX_EXTENSION: u32 = 0x8000;
|
||||
const UNIX_FILE_TYPE: u32 = 0o170000;
|
||||
const UNIX_SYMLINK: u32 = 0o120000;
|
||||
|
||||
const COPY_CHUNK: usize = 256 * 1024;
|
||||
|
||||
/// The archive format a file starts with, by its signature rather than its name.
|
||||
pub fn kind(header: &[u8]) -> Option<Kind> {
|
||||
if header.starts_with(SEVEN_ZIP_SIGNATURE) {
|
||||
Some(Kind::SevenZip)
|
||||
} else if header.starts_with(RAR_SIGNATURE) {
|
||||
Some(Kind::Rar)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpacks `archive` into `destination`, which must exist, and returns how many files it wrote.
|
||||
/// `progress` gets the bytes written so far and the archive's unpacked total, and returns false
|
||||
/// to cancel.
|
||||
pub fn extract(archive: &Path, destination: &Path, progress: &mut dyn FnMut(u64, u64) -> bool) -> Outcome<u64> {
|
||||
let mut header = [0u8; 8];
|
||||
let read = File::open(archive)?.read(&mut header)?;
|
||||
match kind(&header[..read]) {
|
||||
Some(Kind::SevenZip) => extract_seven_zip(archive, destination, progress),
|
||||
Some(Kind::Rar) => extract_rar(archive, destination, progress),
|
||||
None => Err(Failure::Error("The file is neither a 7z nor a RAR archive.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an archive member named `name` goes below `destination`: the launcher's zip rule, where
|
||||
/// either slash separates folders (Windows archivers store backslashes), leading and empty parts
|
||||
/// are dropped, and `..` anywhere is refused.
|
||||
pub fn member_path(destination: &Path, name: &str) -> Outcome<PathBuf> {
|
||||
let mut path = destination.to_path_buf();
|
||||
let mut parts = 0;
|
||||
for part in name.split(['/', '\\']) {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => return Err(Failure::Error(format!("The archive has a file outside its own folder ({name})."))),
|
||||
_ => {
|
||||
path.push(part);
|
||||
parts += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts == 0 {
|
||||
return Err(Failure::Error(format!("The archive has a file with no usable name ({name}).")));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn create(path: &Path) -> Outcome<File> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(File::create(path)?)
|
||||
}
|
||||
|
||||
fn extract_seven_zip(archive: &Path, destination: &Path, progress: &mut dyn FnMut(u64, u64) -> bool) -> Outcome<u64> {
|
||||
let mut reader = ArchiveReader::open(archive, Password::empty()).map_err(seven_zip_error)?;
|
||||
let total: u64 = reader.archive().files.iter().filter(|entry| wanted_seven_zip(entry)).map(|entry| entry.size).sum();
|
||||
if !progress(0, total) {
|
||||
return Err(Failure::Cancelled);
|
||||
}
|
||||
let mut done = 0u64;
|
||||
let mut files = 0u64;
|
||||
// The callback can only return the decoder's error type, so ours waits here while the walk stops.
|
||||
let mut failure: Option<Failure> = None;
|
||||
let walked = reader.for_each_entries(|entry, data| {
|
||||
if !wanted_seven_zip(entry) {
|
||||
// A solid block decodes in order: whatever this entry holds must still be read past.
|
||||
io::copy(data, &mut io::sink())?;
|
||||
return Ok(true);
|
||||
}
|
||||
let step = (|| -> Outcome<()> {
|
||||
let mut file = create(&member_path(destination, &entry.name)?)?;
|
||||
let mut buffer = vec![0u8; COPY_CHUNK];
|
||||
loop {
|
||||
let read = data.read(&mut buffer).map_err(seven_zip_read_error)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(&buffer[..read])?;
|
||||
done += read as u64;
|
||||
if !progress(done, total) {
|
||||
return Err(Failure::Cancelled);
|
||||
}
|
||||
}
|
||||
files += 1;
|
||||
Ok(())
|
||||
})();
|
||||
match step {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) => {
|
||||
failure = Some(error);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(failure) = failure {
|
||||
return Err(failure);
|
||||
}
|
||||
walked.map_err(seven_zip_error)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// A file with contents to write: not a directory, a deletion marker or a symbolic link.
|
||||
fn wanted_seven_zip(entry: &ArchiveEntry) -> bool {
|
||||
let link = entry.has_windows_attributes
|
||||
&& entry.windows_attributes & SEVEN_ZIP_UNIX_EXTENSION != 0
|
||||
&& (entry.windows_attributes >> 16) & UNIX_FILE_TYPE == UNIX_SYMLINK;
|
||||
!entry.is_directory && !entry.is_anti_item && !link
|
||||
}
|
||||
|
||||
fn seven_zip_error(error: sevenz_rust2::Error) -> Failure {
|
||||
use sevenz_rust2::Error;
|
||||
match error {
|
||||
// AES is left out of the build: nothing here could ask for the password anyway.
|
||||
Error::PasswordRequired | Error::MaybeBadPassword(_) => Failure::Error("The archive is password-protected.".into()),
|
||||
Error::UnsupportedCompressionMethod(method) if method.starts_with("AES") => {
|
||||
Failure::Error("The archive is password-protected.".into())
|
||||
}
|
||||
Error::UnsupportedCompressionMethod(method) => {
|
||||
Failure::Error(format!("The 7z archive uses a compression method this headset cannot unpack ({method})."))
|
||||
}
|
||||
Error::ChecksumVerificationFailed => damaged(),
|
||||
other => Failure::Error(format!("The 7z archive could not be unpacked: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoder error met while reading a file's contents, which the decoder wraps in io::Error.
|
||||
fn seven_zip_read_error(error: io::Error) -> Failure {
|
||||
match error.get_ref().and_then(|inner| inner.downcast_ref::<sevenz_rust2::Error>()) {
|
||||
Some(sevenz_rust2::Error::ChecksumVerificationFailed) => damaged(),
|
||||
_ => Failure::Error(format!("The 7z archive could not be unpacked: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn damaged() -> Failure {
|
||||
Failure::Error("The archive is damaged: a file in it does not match its checksum.".into())
|
||||
}
|
||||
|
||||
fn extract_rar(archive: &Path, destination: &Path, progress: &mut dyn FnMut(u64, u64) -> bool) -> Outcome<u64> {
|
||||
let parsed = rars::ArchiveReader::read_path(archive).map_err(rar_error)?;
|
||||
let members: Vec<_> = parsed.members().map(|member| member.meta).collect();
|
||||
if members.iter().any(|member| member.is_split_before || member.is_split_after) {
|
||||
return Err(Failure::Error("The archive is one part of a split RAR archive, which cannot be installed on its own.".into()));
|
||||
}
|
||||
if members.iter().any(|member| member.is_encrypted) {
|
||||
return Err(Failure::Error("The archive is password-protected.".into()));
|
||||
}
|
||||
let total: u64 = members.iter().filter(|member| !member.is_directory).map(|member| member.unpacked_size).sum();
|
||||
if !progress(0, total) {
|
||||
return Err(Failure::Cancelled);
|
||||
}
|
||||
// The writers rars asks for must own what they use, so they count into a shared cell, and
|
||||
// progress is reported as each file starts. Links never reach the callback.
|
||||
let written = Rc::new(Cell::new(0u64));
|
||||
let mut files = 0u64;
|
||||
let mut failure: Option<Failure> = None;
|
||||
let extracted = parsed.extract_to(None, |meta| {
|
||||
if meta.is_directory {
|
||||
return Ok(Box::new(io::sink()) as Box<dyn Write>);
|
||||
}
|
||||
let opened = (|| -> Outcome<File> {
|
||||
if !progress(written.get(), total) {
|
||||
return Err(Failure::Cancelled);
|
||||
}
|
||||
create(&member_path(destination, &String::from_utf8_lossy(&meta.name))?)
|
||||
})();
|
||||
match opened {
|
||||
Ok(file) => {
|
||||
files += 1;
|
||||
Ok(Box::new(CountingWriter { inner: file, written: Rc::clone(&written) }) as Box<dyn Write>)
|
||||
}
|
||||
Err(error) => {
|
||||
let message = match &error {
|
||||
Failure::Error(message) => message.clone(),
|
||||
Failure::Cancelled => "cancelled".into(),
|
||||
};
|
||||
failure = Some(error);
|
||||
Err(rars::Error::from(io::Error::other(message)))
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(failure) = failure {
|
||||
return Err(failure);
|
||||
}
|
||||
extracted.map_err(rar_error)?;
|
||||
progress(written.get(), total);
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
struct CountingWriter {
|
||||
inner: File,
|
||||
written: Rc<Cell<u64>>,
|
||||
}
|
||||
|
||||
impl Write for CountingWriter {
|
||||
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
|
||||
let count = self.inner.write(data)?;
|
||||
self.written.set(self.written.get() + count as u64);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> { self.inner.flush() }
|
||||
}
|
||||
|
||||
fn rar_error(error: rars::Error) -> Failure {
|
||||
match error {
|
||||
rars::Error::NeedPassword => Failure::Error("The archive is password-protected.".into()),
|
||||
other => Failure::Error(format!("The RAR archive could not be unpacked: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn message(outcome: Outcome<PathBuf>) -> String {
|
||||
match outcome {
|
||||
Ok(path) => panic!("accepted {}", path.display()),
|
||||
Err(Failure::Error(message)) => message,
|
||||
Err(Failure::Cancelled) => panic!("cancelled"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_are_told_by_their_signature() {
|
||||
assert_eq!(kind(b"7z\xBC\xAF\x27\x1C\x00\x04"), Some(Kind::SevenZip));
|
||||
assert_eq!(kind(b"Rar!\x1a\x07\x00\xcf"), Some(Kind::Rar));
|
||||
assert_eq!(kind(b"Rar!\x1a\x07\x01\x00"), Some(Kind::Rar));
|
||||
assert_eq!(kind(b"PK\x03\x04"), None);
|
||||
assert_eq!(kind(b"7z"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_stay_below_the_destination() {
|
||||
let root = Path::new("staging");
|
||||
assert_eq!(member_path(root, "Patches\\Menu.szs").ok(), Some(root.join("Patches").join("Menu.szs")));
|
||||
assert_eq!(member_path(root, "/Common.szs").ok(), Some(root.join("Common.szs")));
|
||||
assert_eq!(member_path(root, "a/./b//c.szs").ok(), Some(root.join("a").join("b").join("c.szs")));
|
||||
for name in ["../escape.szs", "a/../../escape.szs", "a\\..\\b.szs", ".."] {
|
||||
assert!(message(member_path(root, name)).contains("outside"), "{name}");
|
||||
}
|
||||
for name in ["", "/", "./."] {
|
||||
assert!(message(member_path(root, name)).contains("no usable name"), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
//! v2.0.0-alpha.10, MIT OR Apache-2.0) with progress reporting and cancellation added, so both
|
||||
//! installers write the same `sys/`, `files/`, `disc/` and `*.bin` layout that the runtime's DVD
|
||||
//! layer reads.
|
||||
//!
|
||||
//! It also unpacks the .7z and .rar mods the Patches page installs
|
||||
//! (`org.wiicompiled.quest.launcher.ModArchive`, see `archive.rs`).
|
||||
|
||||
mod archive;
|
||||
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
@@ -26,7 +31,7 @@ use std::{
|
||||
use jni::{
|
||||
JNIEnv,
|
||||
objects::{JObject, JString, JValue},
|
||||
sys::{jbyteArray, jint, jstring},
|
||||
sys::{jbyteArray, jint, jlong, jstring},
|
||||
};
|
||||
use nod::{
|
||||
common::PartitionKind,
|
||||
@@ -298,3 +303,28 @@ pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_extract<'loca
|
||||
extract(fd, Path::new(&out_dir), &mut report)
|
||||
})
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_org_wiicompiled_quest_launcher_ModArchive_extract<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
archive_path: JString<'local>,
|
||||
out_dir: JString<'local>,
|
||||
listener: JObject<'local>,
|
||||
) -> jlong {
|
||||
guarded(&mut env, 0, |env| {
|
||||
let archive_path: String = env.get_string(&archive_path)?.into();
|
||||
let out_dir: String = env.get_string(&out_dir)?.into();
|
||||
// An exception thrown by the listener stops the extraction and stays pending for Java.
|
||||
let mut report = |done: u64, total: u64| -> bool {
|
||||
env.call_method(&listener, "update", "(JJ)Z", &[JValue::Long(done as i64), JValue::Long(total as i64)])
|
||||
.and_then(|value| value.z())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
match archive::extract(Path::new(&archive_path), Path::new(&out_dir), &mut report) {
|
||||
Ok(files) => Ok(files as jlong),
|
||||
Err(archive::Failure::Error(message)) => Err(Failure::Error(message)),
|
||||
Err(archive::Failure::Cancelled) => Err(Failure::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
+48
-15
@@ -191,14 +191,27 @@ mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR
|
||||
logging). The launch-time geometry (`render_scale`, `hud_distance_meters`,
|
||||
`hud_width_meters`) is only reachable here, not from the in-headset panel.
|
||||
|
||||
**Patches**, between the two, is the PC launcher's mods page without its mod
|
||||
browser (`PatchesPage`, `ModLibrary`). Import takes one or more picked files,
|
||||
asks for a name and makes them one mod under `WiiCompiledOpenXRVR/Mods/<name>/`
|
||||
with the PC's `<name>.ini` (Name, Author, ModID, IsEnabled, Priority), so a
|
||||
`Mods` folder copied from WheelWizard reads the same. Unlike the PC's Import, a
|
||||
picked `.zip` is unpacked, since there is no browser to install downloaded mods;
|
||||
`.7z` and `.rar` are refused. Each mod can be switched off, moved up or down,
|
||||
renamed or deleted. As on the PC, mods only change Retro Rewind: its Play first
|
||||
**Patches**, between the two, is the PC launcher's mods page (`PatchesPage`,
|
||||
`ModLibrary`). Import takes one or more picked files, asks for a name and makes
|
||||
them one mod under `WiiCompiledOpenXRVR/Mods/<name>/` with the PC's
|
||||
`<name>.ini` (Name, Author, ModID, IsEnabled, Priority), so a `Mods` folder
|
||||
copied from WheelWizard reads the same. Unlike the PC's Import, a picked
|
||||
`.zip`, `.7z` or `.rar` is unpacked into the mod rather than stored in it.
|
||||
Browse opens the mod browser in place of the list (`ModBrowser`, `GameBanana`,
|
||||
`ModInstaller`), WheelWizard's `ModBrowserWindow` and `ModContent`: GameBanana's
|
||||
Mario Kart Wii mods (game 5896) through the same two public API calls, a name
|
||||
search a page at a time as the list scrolls and a mod's profile page. An empty
|
||||
search lists "Mod" ("Patches" with Patches only on), mods tagged `Patches` come
|
||||
first or alone, and rated mods are left out, all as on the PC. Download and
|
||||
Install asks for the mod's name first, then downloads into the app's cache,
|
||||
checks the file against the MD5 (or size) GameBanana lists, and installs it
|
||||
like an Import with the mod's author and GameBanana id, which is how the
|
||||
browser shows it as Installed with Uninstall; View Mod in a row's menu opens it
|
||||
again. A mod with several files asks which one (the PC takes the first, often
|
||||
an older version). Only one install runs at a time, sharing Import's slot; the
|
||||
download has its own thread, so Play never waits behind it. Each mod can be
|
||||
switched off, moved up or down, renamed or deleted. As on the PC, mods only
|
||||
change Retro Rewind: its Play first
|
||||
flattens the enabled mods into `RetroRewind6/Patches` exactly like
|
||||
`ModsLaunchService.PrepareModsForLaunch` (the top of the list wins a file both
|
||||
carry, `<name>.<tag>.szs` archives take their mod's priority as a prefix, and
|
||||
@@ -252,12 +265,24 @@ v2.0.0-alpha.10, so both write the same layout:
|
||||
run never costs a working `DATA`.
|
||||
|
||||
nod is the one piece of native code the launcher loads: `android/nod-jni` is
|
||||
a Rust `cdylib` with three JNI calls (header, read one file, extract). The
|
||||
extraction is nodtool's `extract` command, plus progress reporting and
|
||||
cancellation. It reads the picker's file descriptor with `pread`, so nod's
|
||||
preloader threads each hold their own clone. Wii partition decryption needs
|
||||
the common key, and that key lives in the nod crate fetched at build time, not
|
||||
in this repository. The PC installer is the same way: it downloads nodtool.
|
||||
a Rust `cdylib` with three JNI calls for discs (header, read one file,
|
||||
extract). The extraction is nodtool's `extract` command, plus progress
|
||||
reporting and cancellation. It reads the picker's file descriptor with
|
||||
`pread`, so nod's preloader threads each hold their own clone. Wii partition
|
||||
decryption needs the common key, and that key lives in the nod crate fetched at
|
||||
build time, not in this repository. The PC installer is the same way: it
|
||||
downloads nodtool.
|
||||
|
||||
A fourth call (`ModArchive.extract`, `src/archive.rs`) unpacks the `.7z` and
|
||||
`.rar` mods GameBanana serves, which WheelWizard opens with SharpCompress and
|
||||
Android cannot open at all: sevenz-rust2 (LZMA, LZMA2, PPMd, BZip2, Deflate,
|
||||
BCJ/BCJ2; no AES, since there is no password to give) and rars (RAR 1.5 to 4.x
|
||||
and RAR5), both pure Rust and both checking every file's CRC or hash. It
|
||||
writes regular files only, refuses a name that climbs out with `..` as the
|
||||
zip reader does, and tells the formats apart by signature, not by name. On the
|
||||
PC it was checked against 7-Zip on nine real mods (three 7z, three RAR4, three
|
||||
RAR5, byte-identical) and on flipped and truncated copies, which all fail. Its
|
||||
path and signature rules have unit tests in `archive.rs`.
|
||||
|
||||
`GameSetupService` runs the job as a `dataSync` foreground service with a
|
||||
partial wake lock. A multi-minute extraction then survives the panel being
|
||||
@@ -501,7 +526,7 @@ Android facts this design rests on, all measured on a Quest 3:
|
||||
|
||||
Prerequisites on the Windows host (all already present on the machine this
|
||||
was developed on): JDK 17, Android SDK with platform 34+, NDK `29.0.14206865`,
|
||||
SDK CMake `3.22.1`, `adb`, Rust 1.85+ with `rustup target add aarch64-linux-android`,
|
||||
SDK CMake `3.22.1`, `adb`, Rust 1.93+ with `rustup target add aarch64-linux-android`,
|
||||
the .NET 10 SDK (for the headset's translator; the APK build downloads ~70 MB of Termux
|
||||
packages into `android/.dependencies` the first time); a translated graph for your own disc (the
|
||||
installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline).
|
||||
@@ -665,6 +690,14 @@ build log is the newest `Logs/build_*.log`:
|
||||
adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --ez org.wiicompiled.quest.debug.BUILD_GAME true
|
||||
```
|
||||
|
||||
It opens the mod browser the same way, on a GameBanana mod when one is named, and with
|
||||
`INSTALL_MOD` installs that mod as Download and Install would (its first file, the suggested
|
||||
name); `adb logcat -s WiiCompiledLauncher` shows the searches and the install:
|
||||
|
||||
```powershell
|
||||
adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --ei org.wiicompiled.quest.debug.MOD_BROWSER 699980 --ez org.wiicompiled.quest.debug.INSTALL_MOD true
|
||||
```
|
||||
|
||||
Performance, measured 2026-09-16 on a 50cc Luigi Circuit start with the player
|
||||
idle, over 40 s, with an optimized build (`-O3`, translated code `-O2`,
|
||||
`-mcpu=cortex-a77`):
|
||||
|
||||
Reference in new issue
Block a user