From be161a028149e4a39fbc0f2846dd796208766d99 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 02:09:53 +0200 Subject: [PATCH] Added Quest profiles page; - Added vector drawable icons: ic_chart, ic_code, ic_copy, ic_star, ic_translation, ic_user_circle. - Created layout file for the profiles page, including UI elements for displaying user profiles, history, and statistics. - Implemented unit tests for Retro WFC functionality, ensuring correct parsing of history, online friend codes, badges, and Mii images. - Developed tests for Rksys profiles, validating friend code generation, license parsing, and handling of ratings. --- .../java/org/wiicompiled/quest/GameStorage.kt | 20 + .../wiicompiled/quest/launcher/BadgeView.kt | 89 +++ .../quest/launcher/LauncherActivity.kt | 24 +- .../quest/launcher/ProfileStore.kt | 191 ++++++ .../quest/launcher/ProfilesPage.kt | 419 ++++++++++++ .../wiicompiled/quest/launcher/RetroWfc.kt | 174 +++++ .../quest/launcher/RksysProfiles.kt | 131 ++++ .../quest/launcher/SettingsPage.kt | 1 + .../quest/launcher/SidebarProfileCard.kt | 48 ++ .../quest/launcher/VrHistoryChart.kt | 81 +++ .../src/main/res/color/profile_slot_text.xml | 8 + .../src/main/res/drawable/bg_profile_glow.xml | 9 + .../src/main/res/drawable/bg_profile_slot.xml | 6 + .../main/res/drawable/bg_profile_slots.xml | 7 + .../main/res/drawable/bg_sidebar_profile.xml | 25 + .../src/main/res/drawable/ic_arrow_right.xml | 13 + .../app/src/main/res/drawable/ic_award.xml | 13 + .../app/src/main/res/drawable/ic_chart.xml | 11 + android/app/src/main/res/drawable/ic_code.xml | 13 + android/app/src/main/res/drawable/ic_copy.xml | 13 + android/app/src/main/res/drawable/ic_star.xml | 13 + .../src/main/res/drawable/ic_translation.xml | 11 + .../src/main/res/drawable/ic_user_circle.xml | 11 + .../src/main/res/layout/activity_launcher.xml | 82 +++ .../app/src/main/res/layout/page_profiles.xml | 615 ++++++++++++++++++ android/app/src/main/res/values/colors.xml | 3 + android/app/src/main/res/values/strings.xml | 42 ++ android/app/src/main/res/values/themes.xml | 37 ++ .../quest/launcher/RetroWfcTest.kt | 87 +++ .../quest/launcher/RksysProfilesTest.kt | 106 +++ docs/quest-port.md | 18 + 31 files changed, 2318 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/BadgeView.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryChart.kt create mode 100644 android/app/src/main/res/color/profile_slot_text.xml create mode 100644 android/app/src/main/res/drawable/bg_profile_glow.xml create mode 100644 android/app/src/main/res/drawable/bg_profile_slot.xml create mode 100644 android/app/src/main/res/drawable/bg_profile_slots.xml create mode 100644 android/app/src/main/res/drawable/bg_sidebar_profile.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_right.xml create mode 100644 android/app/src/main/res/drawable/ic_award.xml create mode 100644 android/app/src/main/res/drawable/ic_chart.xml create mode 100644 android/app/src/main/res/drawable/ic_code.xml create mode 100644 android/app/src/main/res/drawable/ic_copy.xml create mode 100644 android/app/src/main/res/drawable/ic_star.xml create mode 100644 android/app/src/main/res/drawable/ic_translation.xml create mode 100644 android/app/src/main/res/drawable/ic_user_circle.xml create mode 100644 android/app/src/main/res/layout/page_profiles.xml create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/RksysProfilesTest.kt diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt index ac215d7..7cb7057 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt @@ -50,6 +50,26 @@ object GameStorage { fun configFile(context: Context): File = File(gameRoot(context), "Config.toml") + /** + * The runtime's NAND (ManagedNandRootPath in runtime/include/nand_path.h): `[paths] nand_root` + * when Config.toml sets one, relative to the config's folder, otherwise NAND beside it. + */ + fun nandDirectory(context: Context): File { + val configured = runCatching { TomlConfig.parse(configFile(context).readText()).string("paths", "nand_root") }.getOrNull() + if (configured.isNullOrBlank()) return File(gameRoot(context), "NAND") + val path = File(configured) + return if (path.isAbsolute) path else File(gameRoot(context), configured) + } + + /** + * Retro Rewind's save: the pack's Riivolution XML sends the game's saves to + * riivolution/save/RetroWFC/ beside the pack, and the Quest only runs PAL. + */ + fun retroRewindSave(context: Context): File = File(gameRoot(context), "riivolution/save/RetroWFC/RMCP/rksys.dat") + + /** Pulsar's record of the VR and BR Retro Rewind plays with, per profile. */ + fun retroRewindRatings(context: Context): File = File(nandDirectory(context), "shared2/Pulsar/RetroRewind6/RRRating.pul") + fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs") /** Game packages dropped here (adb push, Build-QuestGame.ps1 -Install) are imported when the launcher opens. */ diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/BadgeView.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/BadgeView.kt new file mode 100644 index 0000000..1edd509 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/BadgeView.kt @@ -0,0 +1,89 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import org.wiicompiled.quest.R + +/** + * One of WheelWizard's badges (Views/Components/Badge.axaml), drawn in proportion to its size. A + * role badge is two discs with an icon; a tournament medal is a small disc behind the Award ribbon, + * turned 21 degrees, in gold, silver or bronze. The tip shows when the pointer rests on it. + */ +class BadgeView(context: Context, private val badge: RetroWfc.Badge) : View(context) { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + init { + tooltipText = badge.tip + contentDescription = badge.tip + } + + override fun onDraw(canvas: Canvas) { + val size = minOf(width, height).toFloat() + if (size <= 0f) return + canvas.save() + canvas.translate((width - size) / 2f, (height - size) / 2f) + when (badge) { + RetroWfc.Badge.WhWzDev -> role(canvas, size, res(R.color.primary_100), res(R.color.primary_400), R.drawable.ic_code, res(R.color.primary_900)) + RetroWfc.Badge.RrDev -> role(canvas, size, res(R.color.warning_100), res(R.color.warning_500), R.drawable.ic_code, res(R.color.warning_800)) + RetroWfc.Badge.Translator -> role(canvas, size, 0xFFE9C7FF.toInt(), 0xFF8A01CB.toInt(), R.drawable.ic_translation, 0xFF400C55.toInt()) + RetroWfc.Badge.TranslatorLead -> { + discs(canvas, size, 0xFFFFC7FB.toInt(), 0xFFCB01AE.toInt()) + // The translation icon sits low and right, with a small star above it on the left. + icon(canvas, R.drawable.ic_translation, 0xFF550C48.toInt(), RectF(size * 3 / 14, size * 2 / 14, size * 12 / 14, size * 11 / 14)) + icon(canvas, R.drawable.ic_star, 0xFF550C48.toInt(), RectF(size * 1.5f / 14, size * 5.5f / 14, size * 5.5f / 14, size * 8.5f / 14)) + } + RetroWfc.Badge.Heart -> { + discs(canvas, size, 0xFFA7FCFF.toInt(), 0xFFF0F0EC.toInt()) + icon(canvas, R.drawable.ic_heart, 0xFF72F2FF.toInt(), RectF(size / 4, size * 0.3f, size * 3 / 4, size * 0.8f)) + } + RetroWfc.Badge.Firestarter_GoldWinner, + RetroWfc.Badge.SummitShowdown_GoldWinner, + RetroWfc.Badge.Leafstruck_GoldWinner -> medal(canvas, size, res(R.color.warning_100), res(R.color.warning_600)) + RetroWfc.Badge.Firestarter_SilverWinner, + RetroWfc.Badge.SummitShowdown_SilverWinner, + RetroWfc.Badge.Leafstruck_SilverWinner -> medal(canvas, size, res(R.color.neutral_50), res(R.color.neutral_400)) + RetroWfc.Badge.Firestarter_BronzeWinner, + RetroWfc.Badge.SummitShowdown_BronzeWinner, + RetroWfc.Badge.Leafstruck_BronzeWinner -> medal(canvas, size, 0xFFFFD19D.toInt(), 0xFFEC5616.toInt()) + } + canvas.restore() + } + + /** A 14-unit grid: the outer disc fills it, the inner one leaves a unit, the icon two. */ + private fun role(canvas: Canvas, size: Float, outer: Int, inner: Int, icon: Int, fill: Int) { + discs(canvas, size, outer, inner) + icon(canvas, icon, fill, RectF(size * 2 / 14, size * 2 / 14, size * 12 / 14, size * 12 / 14)) + } + + private fun discs(canvas: Canvas, size: Float, outer: Int, inner: Int) { + paint.color = outer + canvas.drawCircle(size / 2, size / 2, size / 2, paint) + paint.color = inner + canvas.drawCircle(size / 2, size / 2, size * 6 / 14, paint) + } + + /** Rows of 1, 2 and 2 fifths and columns of 1, 2 and 1 quarters: the disc takes the middle cell. */ + private fun medal(canvas: Canvas, size: Float, disc: Int, ribbon: Int) { + paint.color = disc + val cell = RectF(size / 4, size / 5, size * 3 / 4, size * 3 / 5) + val radius = minOf(cell.width(), cell.height()) / 2 + canvas.drawRoundRect(cell, radius, radius, paint) + canvas.save() + canvas.rotate(21f, size / 2, size / 2) + icon(canvas, R.drawable.ic_award, ribbon, RectF(0f, 0f, size, size)) + canvas.restore() + } + + private fun icon(canvas: Canvas, drawable: Int, tint: Int, box: RectF) { + val icon = context.getDrawable(drawable)?.mutate() ?: return + icon.setTint(tint) + icon.setBounds(box.left.toInt(), box.top.toInt(), box.right.toInt(), box.bottom.toInt()) + icon.draw(canvas) + } + + private fun res(color: Int): Int = context.getColor(color) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 95af5da..bb9ad3e 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -28,8 +28,9 @@ 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 finds mods for Retro - * Rewind on GameBanana or imports them, and a Settings page that edits Config.toml. + * with a Home page that sets up and starts the game, a My profiles page that shows the licences + * of Retro Rewind's save, 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 @@ -42,15 +43,17 @@ import org.wiicompiled.quest.R */ class LauncherActivity : Activity() { - private enum class Page { Home, Patches, Settings } + private enum class Page { Home, Profiles, Patches, Settings } /** What Home's main and secondary buttons do. */ private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private lateinit var navHome: View + private lateinit var navProfiles: View private lateinit var navPatches: View private lateinit var navSettings: View private lateinit var homePage: View + private lateinit var profilesView: View private lateinit var patchesView: View private lateinit var settingsView: View private lateinit var trails: WheelTrailsView @@ -68,6 +71,8 @@ class LauncherActivity : Activity() { private lateinit var gameToggle: LinearLayout private lateinit var settings: SettingsPage private lateinit var patches: PatchesPage + private lateinit var profilesPage: ProfilesPage + private lateinit var sidebarProfile: SidebarProfileCard /** The games this APK carries a kit for, and the one the player picked. */ private val profiles: List by lazy { GameProfile.available(this) } @@ -101,9 +106,11 @@ class LauncherActivity : Activity() { } navHome = findViewById(R.id.nav_home) + navProfiles = findViewById(R.id.nav_profiles) navPatches = findViewById(R.id.nav_patches) navSettings = findViewById(R.id.nav_settings) homePage = findViewById(R.id.page_home) + profilesView = findViewById(R.id.page_profiles) patchesView = findViewById(R.id.page_patches) settingsView = findViewById(R.id.page_settings) trails = findViewById(R.id.home_trails) @@ -140,11 +147,14 @@ class LauncherActivity : Activity() { patches = PatchesPage(this, patchesView) { openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker) } + sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) } + profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) } savedInstanceState?.getString(KEY_TAB)?.let { name -> SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) } navHome.setOnClickListener { showPage(Page.Home) } + navProfiles.setOnClickListener { showPage(Page.Profiles) } navPatches.setOnClickListener { showPage(Page.Patches) } navSettings.setOnClickListener { showPage(Page.Settings) } playButton.setOnClickListener { perform(mainAction) } @@ -189,6 +199,9 @@ class LauncherActivity : Activity() { refresh() GameSetup.addListener(setupListener) patches.attach() + // The game may have changed the save while it ran; the profiles page reads it again itself. + if (page != Page.Profiles) sidebarProfile.refresh() + profilesPage.setVisible(page == Page.Profiles) // The game's process can take a moment to go away after its activity // closes, which would still read as running. val runningAtResume = isGameRunning() @@ -200,6 +213,7 @@ class LauncherActivity : Activity() { override fun onPause() { GameSetup.removeListener(setupListener) patches.detach() + profilesPage.setVisible(false) super.onPause() } @@ -235,10 +249,13 @@ class LauncherActivity : Activity() { private fun showPage(target: Page) { page = target navHome.isSelected = target == Page.Home + navProfiles.isSelected = target == Page.Profiles navPatches.isSelected = target == Page.Patches navSettings.isSelected = target == Page.Settings homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE + profilesView.visibility = if (target == Page.Profiles) View.VISIBLE else View.GONE patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE + profilesPage.setVisible(target == Page.Profiles) settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE if (target == Page.Home) { trailsAway = true @@ -249,6 +266,7 @@ class LauncherActivity : Activity() { private fun refresh() { when (page) { Page.Home -> refreshHome() + Page.Profiles -> profilesPage.refresh() Page.Patches -> patches.refresh() Page.Settings -> settings.refresh() } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt new file mode 100644 index 0000000..2104612 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt @@ -0,0 +1,191 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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 +import org.wiicompiled.quest.R + +/** + * What the profiles page and the sidebar's profile card show: the licences of Retro Rewind's save, + * which one is primary, and what Retro WFC knows of them (their Mii pictures, who is in a room + * now, their VR history), plus WheelWizard's badges. Files are read and the network asked off the + * main thread, and every answer arrives on it. Mii pictures are also kept on disk, so the sidebar + * shows them at once, even offline. + */ +object ProfileStore { + + /** The four licence slots of the save, an empty one null. */ + data class Snapshot(val licenses: List) { + val any: Boolean get() = licenses.any { it != null } + } + + private const val TAG = "WiiCompiledLauncher" + // LauncherActivity's preferences file, where the launcher keeps its other choices. + private const val PREFERENCES = "launcher" + private const val KEY_PRIMARY = "primaryProfile" + private const val IMAGE_DIRECTORY = "profile-miis" + /** SettingValues.NoName: the name the game's guest Miis carry. */ + private const val GUEST_NAME = "no name" + /** Who is in a room is asked again after this long; WheelWizard polls its live rooms similarly. */ + private const val ONLINE_REFRESH_MS = 30_000L + + private val worker = Executors.newFixedThreadPool(2) { runnable -> Thread(runnable, "Profiles").apply { isDaemon = true } } + private val main by lazy { Handler(Looper.getMainLooper()) } + + // Main thread only. + private val images = HashMap() + private val imagesAsked = HashSet() + private val waiting = HashMap Unit>>() + private var online: Set = emptySet() + private var onlineAt = 0L + private var badges: Map>? = null + + /** Reads Retro Rewind's save; [done] gets null when there is none, or it is not readable. */ + fun load(context: Context, done: (Snapshot?) -> Unit) { + val app = context.applicationContext + worker.execute { + val snapshot = read(app) + main.post { done(snapshot) } + } + } + + private fun read(context: Context): Snapshot? { + val save = RksysProfiles.readLicenses(GameStorage.retroRewindSave(context)) ?: return null + val ratings = try { + GameStorage.retroRewindRatings(context).takeIf { it.isFile }?.readBytes()?.let(RksysProfiles::parseRatings).orEmpty() + } catch (e: IOException) { + emptyMap() + } + return RksysProfiles.parse(save, ratings)?.let(::Snapshot) + } + + /** GameLicenseService's FOCUSED_USER: the licence the sidebar shows and the page opens on. */ + fun primarySlot(context: Context): Int = + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).getInt(KEY_PRIMARY, 0).coerceIn(0, RksysProfiles.SLOTS - 1) + + fun setPrimarySlot(context: Context, slot: Int) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().putInt(KEY_PRIMARY, slot).apply() + } + + /** A licence's name as the PC shows it: a guest Mii's "no name" reads No name. */ + fun displayName(context: Context, license: RksysProfiles.License): String = + if (license.name.isBlank() || license.name == GUEST_NAME) context.getString(R.string.profiles_no_name) else license.name + + /** The licence the sidebar shows: the primary one, or the first there is when its slot is empty. */ + fun sidebarLicense(context: Context, snapshot: Snapshot): RksysProfiles.License? = + snapshot.licenses.getOrNull(primarySlot(context)) ?: snapshot.licenses.firstOrNull { it != null } + + /** + * The Mii picture Retro WFC keeps for [friendCode]. [done] gets the one already known at once + * (from memory or disk), then Retro WFC's own the first time this session it differs, or null + * when there is none at all. A licence never taken online has no friend code, and no picture. + */ + fun miiImage(context: Context, friendCode: String, done: (Bitmap?) -> Unit) { + if (friendCode.isEmpty()) { + done(null) + return + } + val known = images[friendCode] + if (known != null) done(known) + // A fetch already on its way answers everyone who asked meanwhile. + waiting[friendCode]?.let { + it += done + return + } + if (!imagesAsked.add(friendCode)) { + if (known == null) done(null) + return + } + waiting[friendCode] = mutableListOf(done) + val file = File(File(context.applicationContext.cacheDir, IMAGE_DIRECTORY), "$friendCode.png") + worker.execute { + val cached = if (file.isFile) runCatching { file.readBytes() }.getOrNull() else null + if (known == null) cached?.let(::decode)?.let { bitmap -> main.post { remember(friendCode, bitmap, finished = false) } } + val fresh = try { + RetroWfc.miiImage(friendCode) + } catch (e: IOException) { + Log.i(TAG, "No Mii picture for $friendCode from Retro WFC: ${e.message}") + null + } + val bitmap = fresh?.takeIf { !it.contentEquals(cached ?: byteArrayOf()) }?.let(::decode) + if (fresh != null && bitmap != null) { + runCatching { + file.parentFile?.mkdirs() + file.writeBytes(fresh) + } + } + main.post { remember(friendCode, bitmap, finished = true) } + } + } + + /** Hands a picture to everyone waiting for [friendCode]; the last answer also tells those with none. */ + private fun remember(friendCode: String, bitmap: Bitmap?, finished: Boolean) { + if (bitmap != null) images[friendCode] = bitmap + val callbacks = (if (finished) waiting.remove(friendCode) else waiting[friendCode]?.toList()).orEmpty() + when { + bitmap != null -> callbacks.forEach { it(bitmap) } + finished && images[friendCode] == null -> callbacks.forEach { it(null) } + } + } + + private fun decode(bytes: ByteArray): Bitmap? = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + + /** The friend codes in a room now, asked again when the last answer is old; the last known ones when that fails. */ + fun online(done: (Set) -> Unit) { + if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) { + done(online) + return + } + worker.execute { + val fresh = try { + RetroWfc.onlineFriendCodes() + } catch (e: IOException) { + Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}") + null + } + main.post { + if (fresh != null) { + online = fresh + onlineAt = SystemClock.elapsedRealtime() + } + done(online) + } + } + } + + /** WheelWizard's badges by friend code, fetched once a session. */ + fun badges(done: (Map>) -> Unit) { + badges?.let { + done(it) + return + } + worker.execute { + val fresh = try { + RetroWfc.badges() + } catch (e: IOException) { + Log.i(TAG, "WheelWizard's badges are unavailable: ${e.message}") + null + } + main.post { + if (fresh != null) badges = fresh + done(badges.orEmpty()) + } + } + } + + /** [friendCode]'s VR over the last [days] days (999 for all of it). */ + fun history(friendCode: String, days: Int, done: (Result) -> Unit) { + worker.execute { + val result = runCatching { RetroWfc.history(friendCode, days) } + main.post { done(result) } + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt new file mode 100644 index 0000000..781c14c --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt @@ -0,0 +1,419 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.graphics.PointF +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.Spinner +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import java.text.NumberFormat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.wiicompiled.quest.R + +/** + * The launcher's My profiles page, WheelWizard's UserProfilePage (Views/Pages/UserProfilePage.axaml.cs): + * the four licences of Retro Rewind's save, one shown at a time with its Mii, name, friend code and + * badges, beside a carousel of its VR history (VrHistoryGraph) and its numbers. As on the PC, a + * licence is Online, with a glow, while its friend code is in a Retro WFC room, and one licence is + * primary: the one the sidebar's card shows. + * + * It only reads. The headset has no Mii database, so a licence's Mii is the picture Retro WFC keeps + * of it (a licence never taken online has none), and nothing renames a licence or changes its Mii. + * The PC's region picker is left out too, since the Quest only runs PAL. + */ +class ProfilesPage( + private val activity: Activity, + root: View, + /** The save was read again or the primary licence changed: the sidebar card follows. */ + private val changed: (ProfileStore.Snapshot?) -> Unit, +) { + + private val content: View = root.findViewById(R.id.profiles_content) + private val empty: View = root.findViewById(R.id.profiles_empty) + private val card: View = root.findViewById(R.id.profiles_card) + private val slots: LinearLayout = root.findViewById(R.id.profiles_slots) + private val face: View = root.findViewById(R.id.profiles_face) + private val glow: View = root.findViewById(R.id.profiles_glow) + private val mii: ImageView = root.findViewById(R.id.profiles_mii) + private val placeholder: View = root.findViewById(R.id.profiles_placeholder) + private val name: TextView = root.findViewById(R.id.profiles_name) + private val codeRow: View = root.findViewById(R.id.profiles_code_row) + private val code: TextView = root.findViewById(R.id.profiles_code) + private val primaryRadio: RadioButton = root.findViewById(R.id.profiles_primary_radio) + private val badgeRow: LinearLayout = root.findViewById(R.id.profiles_badges) + private val historyPage: View = root.findViewById(R.id.profiles_history) + private val statsPage: View = root.findViewById(R.id.profiles_stats) + private val dots: List = listOf(root.findViewById(R.id.profiles_dot0), root.findViewById(R.id.profiles_dot1)) + + private val historyStart: TextView = root.findViewById(R.id.history_start) + private val historyEnd: TextView = root.findViewById(R.id.history_end) + private val historyChange: TextView = root.findViewById(R.id.history_change) + private val historyRange: TextView = root.findViewById(R.id.history_range) + private val historyLoading: View = root.findViewById(R.id.history_loading) + private val historyMatches: Switch = root.findViewById(R.id.history_matches) + private val historyGraph: View = root.findViewById(R.id.history_graph) + private val historyEmpty: View = root.findViewById(R.id.history_empty) + private val historyEmptyMessage: TextView = root.findViewById(R.id.history_empty_message) + private val historyMax: TextView = root.findViewById(R.id.history_max) + private val historyMin: TextView = root.findViewById(R.id.history_min) + private val historyChart: VrHistoryChart = root.findViewById(R.id.history_chart) + private val historyLabels: List = listOf( + root.findViewById(R.id.history_label_start), + root.findViewById(R.id.history_label_mid), + root.findViewById(R.id.history_label_end), + ) + + private val statsVr: TextView = root.findViewById(R.id.stats_vr) + private val statsBr: TextView = root.findViewById(R.id.stats_br) + private val statsWins: TextView = root.findViewById(R.id.stats_wins) + private val statsRaces: TextView = root.findViewById(R.id.stats_races) + + private var snapshot: ProfileStore.Snapshot? = null + private var slot = -1 + private val current: RksysProfiles.License? get() = snapshot?.licenses?.getOrNull(slot) + private var online: Set = emptySet() + private var badges: Map> = emptyMap() + private var carouselPage = 0 + private var days = DEFAULT_DAYS + private var history: RetroWfc.History? = null + /** The friend code and period the history on screen, or on its way, is for. */ + private var historyFor: Pair? = null + private var historyGeneration = 0 + + private val main = Handler(Looper.getMainLooper()) + private var visible = false + private val onlineTicker = object : Runnable { + override fun run() { + refreshOnline() + main.postDelayed(this, ONLINE_REFRESH_MS) + } + } + + private val slotTabs: List = (0 until RksysProfiles.SLOTS).map { index -> + TextView(activity).apply { + gravity = Gravity.CENTER + textSize = 15f + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + setPadding(dp(8), 0, dp(8), 0) + setTextColor(activity.getColorStateList(R.color.profile_slot_text)) + setBackgroundResource(R.drawable.bg_profile_slot) + isClickable = true + isFocusable = true + setOnClickListener { + if (index != slot) { + slot = index + render() + } + } + slots.addView(this, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + } + } + + init { + root.findViewById(R.id.profiles_copy).setOnClickListener { copyFriendCode() } + root.findViewById(R.id.profiles_primary).setOnClickListener { makePrimary() } + root.findViewById(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) } + root.findViewById(R.id.profiles_next).setOnClickListener { moveCarousel(1) } + + PatchesWidgets.styleSwitch(activity, historyMatches) + historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() } + root.findViewById(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() } + + root.findViewById(R.id.history_days).apply { + background = activity.getDrawable(R.drawable.bg_dropdown) + setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup)) + dropDownVerticalOffset = dp(4) + adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply { + setDropDownViewResource(R.layout.item_dropdown_popup) + } + setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val chosen = DAY_OPTIONS[position] + if (chosen == days) return + days = chosen + current?.let(::loadHistory) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + renderCarousel() + } + + /** While the page is on screen, who is online is asked again now and then, as the PC polls its rooms. */ + fun setVisible(shown: Boolean) { + if (shown == visible) return + visible = shown + main.removeCallbacks(onlineTicker) + if (shown) main.post(onlineTicker) + } + + /** Reads the save again, which the game may have changed since, and shows the licence chosen. */ + fun refresh() { + ProfileStore.load(activity) { loaded -> + if (activity.isDestroyed) return@load + snapshot = loaded + // VrHistoryGraph reloads each time it is shown: races since then count. + historyFor = null + val licenses = loaded?.licenses.orEmpty() + if (licenses.getOrNull(slot) == null) { + // UserProfilePage opens on the focused user, here the primary licence. + val primary = ProfileStore.primarySlot(activity) + slot = if (licenses.getOrNull(primary) != null) primary else licenses.indexOfFirst { it != null } + } + render() + changed(loaded) + } + ProfileStore.badges { loaded -> + badges = loaded + if (!activity.isDestroyed) renderBadges() + } + } + + private fun render() { + val license = current + val hasProfiles = snapshot?.any == true && license != null + content.visibility = if (hasProfiles) View.VISIBLE else View.GONE + empty.visibility = if (hasProfiles) View.GONE else View.VISIBLE + if (license == null) return + + slotTabs.forEachIndexed { index, tab -> + val entry = snapshot?.licenses?.getOrNull(index) + tab.text = entry?.let { ProfileStore.displayName(activity, it) } ?: activity.getString(R.string.profiles_no_license) + tab.isEnabled = entry != null + tab.isSelected = index == slot + tab.setTypeface(null, if (entry == null) Typeface.ITALIC else Typeface.NORMAL) + } + name.text = ProfileStore.displayName(activity, license) + code.text = license.friendCode + codeRow.visibility = if (license.friendCode.isEmpty()) View.GONE else View.VISIBLE + primaryRadio.isChecked = ProfileStore.primarySlot(activity) == license.slot + showMii(license) + renderOnline() + renderBadges() + + statsVr.text = license.vr.toString() + statsBr.text = license.br.toString() + statsWins.text = license.wins.toString() + statsRaces.text = license.races.toString() + loadHistory(license) + } + + private fun showMii(license: RksysProfiles.License) { + val friendCode = license.friendCode + if (mii.tag != friendCode) { + mii.tag = friendCode + mii.setImageDrawable(null) + placeholder.visibility = View.VISIBLE + } + ProfileStore.miiImage(activity, friendCode) { bitmap -> + if (mii.tag != friendCode) return@miiImage + mii.setImageBitmap(bitmap) + placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE + } + } + + // Online, as UserProfilePage.UpdateOnlineBorders shows it. + + private fun refreshOnline() { + ProfileStore.online { codes -> + online = codes + if (!activity.isDestroyed) renderOnline() + } + } + + private fun renderOnline() { + val license = current ?: return + val isOnline = license.friendCode.isNotEmpty() && license.friendCode in online + card.background = box(R.color.neutral_900, if (isOnline) R.color.primary_400 else R.color.neutral_900, 8) + face.background = box(R.color.neutral_950, if (isOnline) R.color.primary_400 else R.color.neutral_600, 4) + glow.visibility = if (isOnline) View.VISIBLE else View.GONE + face.contentDescription = if (isOnline) activity.getString(R.string.profiles_online) else null + } + + private fun renderBadges() { + badgeRow.removeAllViews() + val license = current ?: return + for (badge in badges[license.friendCode].orEmpty()) { + badgeRow.addView(BadgeView(activity, badge), LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginStart = dp(3) }) + } + } + + // Actions + + private fun copyFriendCode() { + val friendCode = current?.friendCode?.takeIf { it.isNotEmpty() } ?: return + activity.getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), friendCode)) + Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show() + } + + /** UserProfilePage.SetUserAsPrimary: the sidebar's card follows the new choice. */ + private fun makePrimary() { + val license = current ?: return + if (ProfileStore.primarySlot(activity) == license.slot) return + ProfileStore.setPrimarySlot(activity, license.slot) + primaryRadio.isChecked = true + Toast.makeText(activity, R.string.profiles_primary_set, Toast.LENGTH_SHORT).show() + changed(snapshot) + } + + private fun moveCarousel(offset: Int) { + carouselPage = Math.floorMod(carouselPage + offset, CAROUSEL_PAGES) + renderCarousel() + } + + private fun renderCarousel() { + historyPage.visibility = if (carouselPage == 0) View.VISIBLE else View.GONE + statsPage.visibility = if (carouselPage == 1) View.VISIBLE else View.GONE + dots.forEachIndexed { index, dot -> + val active = index == carouselPage + dot.layoutParams = dot.layoutParams.apply { width = dp(if (active) 26 else 10) } + dot.background = GradientDrawable().apply { + setColor(activity.getColor(if (active) R.color.primary_300 else R.color.neutral_500)) + cornerRadius = dp(5).toFloat() + alpha = if (active) 255 else 191 + } + } + } + + // VR history, as VrHistoryGraph loads and draws it. + + private fun loadHistory(license: RksysProfiles.License) { + val friendCode = license.friendCode + if (historyFor == friendCode to days) return + historyFor = friendCode to days + val generation = ++historyGeneration + history = null + if (friendCode.isEmpty() || friendCode.none { it in '1'..'9' }) { + // SetNoFriendCodeState: a licence never taken online has nothing to show. + historyLoading.visibility = View.GONE + showHistoryState(activity.getString(R.string.history_no_code)) + historyRange.text = "" + return + } + historyLoading.visibility = View.VISIBLE + ProfileStore.history(friendCode, days) { result -> + if (generation != historyGeneration || activity.isDestroyed) return@history + historyLoading.visibility = View.GONE + result.onSuccess { loaded -> + history = loaded + renderHistory() + }.onFailure { failure -> + historyRange.text = "" + showHistoryState(failure.message ?: failure.toString()) + } + } + } + + /** VrHistoryGraph.ApplyHistoryData: the totals, the range, then the plot by time or by match. */ + private fun renderHistory() { + val data = history ?: return + historyStart.text = grouped(data.starting) + historyEnd.text = grouped(data.ending) + historyChange.text = if (data.totalChange > 0) "+${grouped(data.totalChange)}" else grouped(data.totalChange) + historyChange.setTextColor( + activity.getColor( + when { + data.totalChange > 0 -> R.color.primary_400 + data.totalChange < 0 -> R.color.danger_400 + else -> R.color.neutral_100 + }, + ), + ) + historyRange.text = if (days == LIFETIME_DAYS) { + activity.getString(R.string.history_range_all) + } else { + val fromPattern = if (year(data.from) != year(data.to)) "MMM d, yyyy" else "MMM d" + activity.getString(R.string.history_range, days, date(data.from, fromPattern), date(data.to, "MMM d")) + } + + val entries = data.entries + if (entries.isEmpty()) { + showHistoryState(activity.getString(R.string.history_none_range)) + return + } + historyGraph.visibility = View.VISIBLE + historyEmpty.visibility = View.GONE + val start = entries.first().time + val span = maxOf(1_000L, entries.last().time - start) + val lowest = entries.minOf { it.total } + val highest = entries.maxOf { it.total } + val vrRange = maxOf(1, highest - lowest).toFloat() + historyMax.text = grouped(highest) + historyMin.text = grouped(lowest) + val byMatch = historyMatches.isChecked + val labels = if (byMatch) { + listOf(1, entries.size / 2 + 1, entries.size).map { activity.getString(R.string.history_match, it) } + } else { + val pattern = if (days >= 7) "MMM d" else "MMM d HH:mm" + listOf(start, start + span / 2, entries.last().time).map { date(it, pattern) } + } + historyLabels.zip(labels).forEach { (view, text) -> view.text = text } + historyChart.setPoints( + entries.mapIndexed { index, entry -> + val x = if (byMatch) { + if (entries.size > 1) index.toFloat() / (entries.size - 1) else 0f + } else { + (entry.time - start).toFloat() / span + } + PointF(x, (entry.total - lowest) / vrRange) + }, + ) + } + + /** VrHistoryGraph's empty and error states, which share one look. */ + private fun showHistoryState(message: String) { + historyGraph.visibility = View.GONE + historyEmpty.visibility = View.VISIBLE + historyEmptyMessage.text = message + for (view in listOf(historyStart, historyEnd, historyChange)) view.text = "0" + historyChange.setTextColor(activity.getColor(R.color.neutral_100)) + historyChart.setPoints(emptyList()) + } + + private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value) + + private fun date(time: Long, pattern: String): String = + DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(time)) + + private fun year(time: Long): Int = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).year + + private fun box(fill: Int, stroke: Int, radius: Int) = GradientDrawable().apply { + setColor(activity.getColor(fill)) + setStroke(dp(1), activity.getColor(stroke)) + cornerRadius = dp(radius).toFloat() + } + + private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value) + + private companion object { + const val CAROUSEL_PAGES = 2 + const val DEFAULT_DAYS = 30 + /** What VrHistoryGraph asks for "Lifetime". */ + const val LIFETIME_DAYS = 999 + val DAY_OPTIONS = listOf(1, 7, 30, 60, LIFETIME_DAYS) + val DAY_LABELS = listOf(R.string.history_days_1, R.string.history_days_7, R.string.history_days_30, R.string.history_days_60, R.string.history_days_all) + /** How often who is online is asked again while the page is shown. */ + const val ONLINE_REFRESH_MS = 30_000L + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt new file mode 100644 index 0000000..4cf7a65 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt @@ -0,0 +1,174 @@ +package org.wiicompiled.quest.launcher + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.time.Instant +import java.time.format.DateTimeParseException +import java.util.Base64 +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.wiicompiled.quest.BuildConfig + +/** + * Retro WFC, the server Retro Rewind plays online on, asked what WheelWizard's profile page asks it + * (Features/RrRooms/IRwfcApi): a player's profile, which carries a picture of their Mii, their VR + * history, and the rooms open now. Also WheelWizard's own badges (badges.json), by friend code. + * + * Values are read with plain `opt` and type checks, as in [GameBanana], so Android's org.json and + * the JVM one the tests use agree. + */ +object RetroWfc { + + private const val API = "https://rwfc.net/api" + private const val ROOM_STATUS_URL = "$API/roomstatus" + private const val BADGES_URL = "https://raw.githubusercontent.com/TeamWheelWizard/WheelWizard-Data/main/badges.json" + private const val TIMEOUT_MS = 20_000 + + private val userAgent: String get() = "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}" + + /** One VR change, at [time] in epoch milliseconds, leaving the player at [total]. */ + data class HistoryEntry(val time: Long, val change: Int, val total: Int) + + /** RwfcPlayerVrHistoryResponse: the entries of a period, oldest first, and its totals. */ + data class History( + val from: Long, + val to: Long, + val starting: Int, + val ending: Int, + val totalChange: Int, + val entries: List, + ) + + /** WheelWizard's BadgeVariant, with the tip its Badge component shows. */ + enum class Badge(val tip: String) { + WhWzDev("Wheel Wizard Developer (hiii!)"), + RrDev("Retro Rewind Developer"), + Translator("Translator"), + TranslatorLead("Translator Lead"), + Heart("Heart of the Community"), + Firestarter_GoldWinner("Firestarter Tournament Winner"), + Firestarter_SilverWinner("Firestarter Tournament Runner-Up"), + Firestarter_BronzeWinner("Firestarter Tournament Runner-Up"), + SummitShowdown_GoldWinner("Summit Showdown Tournament Winner"), + SummitShowdown_SilverWinner("Summit Showdown Tournament Runner-Up"), + SummitShowdown_BronzeWinner("Summit Showdown Tournament Runner-Up"), + Leafstruck_GoldWinner("Leafstruck Tournament Winner"), + Leafstruck_SilverWinner("Leafstruck Tournament Runner-Up"), + Leafstruck_BronzeWinner("Leafstruck Tournament Runner-Up"), + } + + fun profileUrl(friendCode: String): String = "$API/leaderboard/player/${encode(friendCode)}" + + fun historyUrl(friendCode: String, days: Int): String = "${profileUrl(friendCode)}/history?days=$days" + + private fun encode(text: String): String = URLEncoder.encode(text, "UTF-8") + + // Parsing + + /** The PNG of the player's Mii a profile carries (64 by 64 pixels), or null without one. */ + fun parseMiiImage(json: String): ByteArray? = parsing { + val encoded = JSONObject(json).text("miiImageBase64") ?: return@parsing null + try { + // The MIME decoder skips line breaks, should a server ever wrap the text, and anything + // else that is not Base64, which leaves nothing. + Base64.getMimeDecoder().decode(encoded).takeIf { it.isNotEmpty() } + } catch (e: IllegalArgumentException) { + null + } + } + + fun parseHistory(json: String): History = parsing { + val root = JSONObject(json) + val entries = (root.opt("history") as? JSONArray)?.let { array -> + (0 until array.length()).mapNotNull { index -> + val entry = array.opt(index) as? JSONObject ?: return@mapNotNull null + HistoryEntry( + time = time(entry.text("date")) ?: return@mapNotNull null, + change = entry.number("vrChange"), + total = entry.number("totalVR"), + ) + } + }.orEmpty().sortedBy { it.time } + History( + from = time(root.text("fromDate")) ?: entries.firstOrNull()?.time ?: 0L, + to = time(root.text("toDate")) ?: entries.lastOrNull()?.time ?: 0L, + starting = root.number("startingVR"), + ending = root.number("endingVR"), + totalChange = root.number("totalVRChange"), + entries = entries, + ) + } + + /** The friend codes of everyone in a room now (RRLiveRooms, as GameLicenseService.RefreshOnlineStatus uses it). */ + fun parseOnlineFriendCodes(json: String): Set = parsing { + val rooms = JSONObject(json).opt("rooms") as? JSONArray ?: return@parsing emptySet() + (0 until rooms.length()).flatMap { index -> + val players = (rooms.opt(index) as? JSONObject)?.opt("players") as? JSONArray ?: return@flatMap emptyList() + (0 until players.length()).mapNotNull { (players.opt(it) as? JSONObject)?.text("friendCode") } + }.toSet() + } + + /** WhWzDataSingletonService.LoadBadgesAsync: friend code to badges, unknown badge names left out. */ + fun parseBadges(json: String): Map> = parsing { + val root = JSONObject(json) + root.keys().asSequence().associateWith { key -> + val names = root.opt(key) as? JSONArray ?: return@associateWith emptyList() + (0 until names.length()).mapNotNull { index -> + (names.opt(index) as? String)?.let { name -> Badge.entries.firstOrNull { it.name == name } } + } + } + } + + private fun time(text: String?): Long? = try { + text?.let { Instant.parse(it).toEpochMilli() } + } catch (e: DateTimeParseException) { + null + } + + private fun parsing(block: () -> T): T = try { + block() + } catch (e: JSONException) { + throw IOException("Retro WFC 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): Int = (opt(key) as? Number)?.toInt() ?: 0 + + // Network; everything below blocks, so callers run it off the main thread. + + /** The Mii picture of [friendCode]'s profile, or null when Retro WFC has never seen it. */ + fun miiImage(friendCode: String): ByteArray? = get(profileUrl(friendCode), missingIsNull = true)?.let(::parseMiiImage) + + fun history(friendCode: String, days: Int): History { + val json = get(historyUrl(friendCode, days), missingIsNull = true) + ?: throw IOException("Retro WFC has no VR history for $friendCode.") + return parseHistory(json) + } + + fun onlineFriendCodes(): Set = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}") + + fun badges(): Map> = parseBadges(get(BADGES_URL) ?: "{}") + + private fun get(url: String, missingIsNull: Boolean = false): String? { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", userAgent) + } + try { + val code = try { + connection.responseCode + } catch (e: IOException) { + throw IOException("Retro WFC could not be reached. Check the headset's internet connection.", e) + } + if (code == HttpURLConnection.HTTP_NOT_FOUND && missingIsNull) return null + if (code !in 200..299) throw IOException("${URL(url).host} answered $code.") + return connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + } finally { + connection.disconnect() + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt new file mode 100644 index 0000000..7bb0bd2 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt @@ -0,0 +1,131 @@ +package org.wiicompiled.quest.launcher + +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.security.MessageDigest + +/** + * The licences of a Mario Kart Wii save, read as WheelWizard's GameLicenseService reads rksys.dat + * (after kazuki-4ys' FaceThief and https://wiki.tockdom.com/wiki/Rksys.dat): after the RKSD0006 + * magic come four RKPD blocks, one per licence, each holding its Mii's name, the profile ID its + * friend code derives from, its VR and BR, and race counts. Retro Rewind keeps the ratings it plays + * with in Pulsar's RRRating.pul, which outranks the save's own when it knows the profile. + * + * Nothing here writes a save: the profiles page only shows what the game recorded. + */ +object RksysProfiles { + + const val SLOTS = 4 + + private const val MAGIC = "RKSD0006" + private const val LICENSE_MAGIC = "RKPD" + private const val LICENSE_SIZE = 0x8CC0 + /** Where the licences end: the magic, then the four blocks. The rest of the save is not read. */ + const val LICENSES_END = 8 + SLOTS * LICENSE_SIZE + + private const val NAME_OFFSET = 0x14 + private const val NAME_CHARS = 10 + private const val PROFILE_ID_OFFSET = 0x5C + private const val VR_OFFSET = 0xB0 + private const val BR_OFFSET = 0xB2 + private const val RACES_OFFSET = 0xB4 + private const val WINS_OFFSET = 0xDC + + private const val RATING_MAGIC = 0x52525254L // "RRRT" + private const val RATING_VERSION = 1 + private const val RATING_ENTRIES = 100 + private const val RATING_ENTRY_SIZE = 16 + private const val RATING_HAS_DATA = 1L + + /** + * One licence as the profiles page shows it. [friendCode] is empty for a licence never taken + * online, which has no profile ID yet. + */ + data class License( + val slot: Int, + val name: String, + val profileId: Long, + val friendCode: String, + val vr: Int, + val br: Int, + val races: Long, + val wins: Long, + ) + + /** A rating from RRRating.pul, already as the game shows it (60.50 is 6050). */ + data class Rating(val vr: Int, val br: Int) + + /** + * The four licence slots of [save], an empty one null, or null when [save] is not a Mario Kart + * Wii save. [ratings] are RRRating.pul's, by profile ID. + */ + fun parse(save: ByteArray, ratings: Map = emptyMap()): List? { + if (save.size < LICENSES_END || String(save, 0, MAGIC.length, Charsets.US_ASCII) != MAGIC) return null + return (0 until SLOTS).map { slot -> + val base = MAGIC.length + slot * LICENSE_SIZE + if (String(save, base, LICENSE_MAGIC.length, Charsets.US_ASCII) != LICENSE_MAGIC) return@map null + val profileId = u32(save, base + PROFILE_ID_OFFSET) + val rating = if (profileId != 0L) ratings[profileId] else null + License( + slot = slot, + name = String(save, base + NAME_OFFSET, NAME_CHARS * 2, Charsets.UTF_16BE).substringBefore('\u0000').trim(), + profileId = profileId, + friendCode = friendCode(profileId), + vr = rating?.vr ?: u16(save, base + VR_OFFSET), + br = rating?.br ?: u16(save, base + BR_OFFSET), + races = u32(save, base + RACES_OFFSET), + wins = u32(save, base + WINS_OFFSET), + ) + } + } + + /** + * FriendCodeGenerator.GetFriendCode: the profile ID with, above it, the top seven bits of the + * MD5 of the ID (little-endian) followed by "JCMR", written as three groups of four digits. + * Empty for ID 0. + */ + fun friendCode(profileId: Long): String { + if (profileId == 0L) return "" + val id = profileId.toInt() + val input = byteArrayOf(id.toByte(), (id ushr 8).toByte(), (id ushr 16).toByte(), (id ushr 24).toByte(), 0x4A, 0x43, 0x4D, 0x52) + val check = (MessageDigest.getInstance("MD5").digest(input)[0].toInt() and 0xFF) ushr 1 + val digits = "%012d".format((check.toLong() shl 32) or profileId) + return "${digits.substring(0, 4)}-${digits.substring(4, 8)}-${digits.substring(8)}" + } + + /** + * RRratingReader: RRRating.pul is "RRRT", version 1 and 100 entries of profile ID, VR and BR + * as floats, and flags. The PC multiplies by 100 and rounds half to even, as .NET's Math.Round. + */ + fun parseRatings(pul: ByteArray): Map { + if (pul.size < 8 + RATING_ENTRIES * RATING_ENTRY_SIZE) return emptyMap() + if (u32(pul, 0) != RATING_MAGIC || u16(pul, 4) != RATING_VERSION || u16(pul, 6) != RATING_ENTRIES) return emptyMap() + val ratings = HashMap() + for (index in 0 until RATING_ENTRIES) { + val entry = 8 + index * RATING_ENTRY_SIZE + val profileId = u32(pul, entry) + if (u32(pul, entry + 12) and RATING_HAS_DATA == 0L || profileId == 0L) continue + ratings[profileId] = Rating(rating(pul, entry + 4), rating(pul, entry + 8)) + } + return ratings + } + + private fun rating(data: ByteArray, offset: Int): Int = Math.rint((Float.fromBits(u32(data, offset).toInt()) * 100f).toDouble()).toInt() + + /** The part of [file] the licences occupy, or null when it is not there or too short to be a save. */ + fun readLicenses(file: File): ByteArray? = try { + RandomAccessFile(file, "r").use { input -> + if (input.length() < LICENSES_END) return null + ByteArray(LICENSES_END).also { input.readFully(it) } + } + } catch (e: IOException) { + null + } + + private fun u16(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun u32(data: ByteArray, offset: Int): Long = + ((data[offset].toLong() and 0xFF) shl 24) or ((data[offset + 1].toLong() and 0xFF) shl 16) or + ((data[offset + 2].toLong() and 0xFF) shl 8) or (data[offset + 3].toLong() and 0xFF) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index b91efa9..c8b5ca2 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -401,6 +401,7 @@ class SettingsPage( 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) + info(R.string.about_credit_title_profiles, activity.getString(R.string.about_credit_profiles), stacked = true) if (BuildConfig.ON_DEVICE_BUILD) { info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true) } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt new file mode 100644 index 0000000..979c7e6 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarProfileCard.kt @@ -0,0 +1,48 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import org.wiicompiled.quest.R + +/** + * WheelWizard's sidebar profile block (Views/Patterns/CurrentUserProfile): the primary licence of + * Retro Rewind's save, with its Mii, name and friend code, opening My profiles. Hidden while the + * save has no licence. + */ +class SidebarProfileCard(private val activity: Activity, private val card: View, open: () -> Unit) { + + private val mii: ImageView = card.findViewById(R.id.sidebar_profile_mii) + private val placeholder: View = card.findViewById(R.id.sidebar_profile_placeholder) + private val name: TextView = card.findViewById(R.id.sidebar_profile_name) + private val code: TextView = card.findViewById(R.id.sidebar_profile_code) + + init { + card.setOnClickListener { open() } + } + + /** Reads the save again, which the game may have changed. */ + fun refresh() = ProfileStore.load(activity, ::show) + + fun show(snapshot: ProfileStore.Snapshot?) { + if (activity.isDestroyed) return + val license = snapshot?.let { ProfileStore.sidebarLicense(activity, it) } + card.visibility = if (license == null) View.GONE else View.VISIBLE + if (license == null) return + name.text = ProfileStore.displayName(activity, license) + code.text = license.friendCode + code.visibility = if (license.friendCode.isEmpty()) View.GONE else View.VISIBLE + val friendCode = license.friendCode + if (mii.tag != friendCode) { + mii.tag = friendCode + mii.setImageDrawable(null) + placeholder.visibility = View.VISIBLE + } + ProfileStore.miiImage(activity, friendCode) { bitmap -> + if (mii.tag != friendCode) return@miiImage + mii.setImageBitmap(bitmap) + placeholder.visibility = if (bitmap == null) View.VISIBLE else View.GONE + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryChart.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryChart.kt new file mode 100644 index 0000000..9704864 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryChart.kt @@ -0,0 +1,81 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.RectF +import android.graphics.Shader +import android.util.AttributeSet +import android.view.View +import org.wiicompiled.quest.R + +/** + * The plot of WheelWizard's VrHistoryGraph: a dark panel with three lines across and three down, + * the area under the VR line in a fading primary gradient, and the line itself. Points are given + * from 0 to 1 on both axes, 0 being the left and the lowest VR. + */ +class VrHistoryChart(context: Context, attrs: AttributeSet? = null) : View(context, attrs) { + + private val density = resources.displayMetrics.density + private val panel = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = context.getColor(R.color.neutral_950) } + private val panelEdge = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = density + color = context.getColor(R.color.neutral_900) + } + private val grid = Paint().apply { + strokeWidth = density + color = context.getColor(R.color.neutral_800) + } + private val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2 * density + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + color = context.getColor(R.color.primary_400) + } + private val area = Paint(Paint.ANTI_ALIAS_FLAG).apply { alpha = (0.30f * 255).toInt() } + + private var points: List = emptyList() + + fun setPoints(normalized: List) { + points = normalized + invalidate() + } + + override fun onDraw(canvas: Canvas) { + val bounds = RectF(0f, 0f, width.toFloat(), height.toFloat()) + val corner = 6 * density + canvas.drawRoundRect(bounds, corner, corner, panel) + canvas.drawRoundRect(bounds.apply { inset(density / 2, density / 2) }, corner, corner, panelEdge) + + val inner = RectF(8 * density, 8 * density, width - 8 * density, height - 8 * density) + if (inner.width() <= 0 || inner.height() <= 0) return + for (fraction in listOf(0f, 0.5f, 1f)) { + val y = inner.top + inner.height() * fraction + canvas.drawLine(inner.left, y, inner.right, y, grid) + val x = inner.left + inner.width() * fraction + canvas.drawLine(x, inner.top, x, inner.bottom, grid) + } + if (points.isEmpty()) return + + fun at(point: PointF) = PointF(inner.left + point.x * inner.width(), inner.bottom - point.y * inner.height()) + val stroke = Path() + val fill = Path().apply { moveTo(inner.left, inner.bottom) } + points.map(::at).forEachIndexed { index, point -> + if (index == 0) stroke.moveTo(point.x, point.y) else stroke.lineTo(point.x, point.y) + fill.lineTo(point.x, point.y) + } + fill.lineTo(inner.right, inner.bottom) + fill.close() + area.shader = LinearGradient( + 0f, inner.top, 0f, inner.bottom, + context.getColor(R.color.primary_300), context.getColor(R.color.primary_700), Shader.TileMode.CLAMP, + ) + canvas.drawPath(fill, area) + canvas.drawPath(stroke, line) + } +} diff --git a/android/app/src/main/res/color/profile_slot_text.xml b/android/app/src/main/res/color/profile_slot_text.xml new file mode 100644 index 0000000..8237a5b --- /dev/null +++ b/android/app/src/main/res/color/profile_slot_text.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_profile_glow.xml b/android/app/src/main/res/drawable/bg_profile_glow.xml new file mode 100644 index 0000000..d1c1035 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_profile_glow.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_profile_slot.xml b/android/app/src/main/res/drawable/bg_profile_slot.xml new file mode 100644 index 0000000..2483280 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_profile_slot.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_profile_slots.xml b/android/app/src/main/res/drawable/bg_profile_slots.xml new file mode 100644 index 0000000..fcb993a --- /dev/null +++ b/android/app/src/main/res/drawable/bg_profile_slots.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_sidebar_profile.xml b/android/app/src/main/res/drawable/bg_sidebar_profile.xml new file mode 100644 index 0000000..9563525 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_sidebar_profile.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_right.xml b/android/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..688b0b1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_award.xml b/android/app/src/main/res/drawable/ic_award.xml new file mode 100644 index 0000000..220ab89 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_award.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_chart.xml b/android/app/src/main/res/drawable/ic_chart.xml new file mode 100644 index 0000000..f8869f4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chart.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_code.xml b/android/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..783d6e6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_copy.xml b/android/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..d6aae81 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_star.xml b/android/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 0000000..c5e244e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_translation.xml b/android/app/src/main/res/drawable/ic_translation.xml new file mode 100644 index 0000000..d2f6e4a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_translation.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_user_circle.xml b/android/app/src/main/res/drawable/ic_user_circle.xml new file mode 100644 index 0000000..2a5cf0c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_user_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 245f874..4dbb0ef 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -39,6 +39,69 @@ android:textSize="16sp" /> + + + + + + + + + + + + + + + + + + + + + + + + + @@ -119,6 +196,11 @@ android:id="@+id/page_home" layout="@layout/page_home" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 6a4737d..9eb9e12 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -16,18 +16,21 @@ #363944 #24252D + #C7FFF0 #8EFFE2 #34EAC5 #01CBA5 #00A488 #03826E #08675A + #0C554A #FFFEE7 #FFFDC1 #FFC800 #D19200 #A66802 + #89510A #FF9DAF #FF6581 #FE2048 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7124f8a..c72f6f1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ General Home + My profiles Patches Settings v%1$s @@ -130,6 +131,45 @@ Installing mods… The mods could not be installed + + My profiles + Retro Rewind save + No profiles + You have to play Retro Rewind at least once in order to see your profiles listed here + No license + No name + Make Primary + The primary profile is the one the sidebar shows. + Set profile as primary + Copy friend code + Copied friend code to clipboard + Friend code + Online + VR + Versus Rating + BR + Battle Rating + Total games won + Total games played + Previous + Next + Start VR + End VR + Net Change + VR History + Matches + Last 24 hours + Last 7 days + Last 30 days + Last 60 days + Lifetime + Past %1$d days (%2$s to %3$s) + Lifetime history + Match %1$d + No VR history + No VR history found for this range yet. + Friend code is required to load VR history. + Mod Browser Back to your mods @@ -188,6 +228,7 @@ The OpenXR SDK by Khronos, and SDL 3 for windowing, audio and controllers. nod by encounter, which reads your disc image, built into this app as libnod_jni. The Patches page\'s mod browser finds mods on GameBanana, as Wheel Wizard\'s does. Their .7z and .rar archives are unpacked by sevenz-rust2 (Apache 2.0) and rars by bitplane (MIT or Apache 2.0), built into libnod_jni. + My profiles reads your licences from Retro Rewind\'s save, and shows each one\'s Mii picture, VR history and online status from Retro WFC (rwfc.net), as Wheel Wizard does. Badges come from Wheel Wizard\'s data. On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator. The Dolphin emulator, the reference for how the Wii behaves. The WiiCompiled logo by inkwreck. @@ -202,6 +243,7 @@ OpenXR and SDL Disc images Mod browser + My profiles On-headset builds Dolphin Logo diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index d9a1c3d..48a59c8 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -144,6 +144,43 @@ true + + + + + + + + + +