mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 03:00:14 +02:00
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.
This commit is contained in:
1 parent
51567c7987
commit
be161a0281
31 files changed
+2318
-3
No files matched your search
@@ -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/<game ID><region> 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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<GameProfile> 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()
|
||||
}
|
||||
|
||||
@@ -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<RksysProfiles.License?>) {
|
||||
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<String, Bitmap>()
|
||||
private val imagesAsked = HashSet<String>()
|
||||
private val waiting = HashMap<String, MutableList<(Bitmap?) -> Unit>>()
|
||||
private var online: Set<String> = emptySet()
|
||||
private var onlineAt = 0L
|
||||
private var badges: Map<String, List<RetroWfc.Badge>>? = 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<String>) -> 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<String, List<RetroWfc.Badge>>) -> 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<RetroWfc.History>) -> Unit) {
|
||||
worker.execute {
|
||||
val result = runCatching { RetroWfc.history(friendCode, days) }
|
||||
main.post { done(result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<View> = 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<TextView> = 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<String> = emptySet()
|
||||
private var badges: Map<String, List<RetroWfc.Badge>> = 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<String, Int>? = 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<TextView> = (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<View>(R.id.profiles_copy).setOnClickListener { copyFriendCode() }
|
||||
root.findViewById<View>(R.id.profiles_primary).setOnClickListener { makePrimary() }
|
||||
root.findViewById<View>(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) }
|
||||
root.findViewById<View>(R.id.profiles_next).setOnClickListener { moveCarousel(1) }
|
||||
|
||||
PatchesWidgets.styleSwitch(activity, historyMatches)
|
||||
historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() }
|
||||
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() }
|
||||
|
||||
root.findViewById<Spinner>(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
|
||||
}
|
||||
}
|
||||
@@ -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<HistoryEntry>,
|
||||
)
|
||||
|
||||
/** 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<String> = 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<String, List<Badge>> = 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 <T> 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<String> = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}")
|
||||
|
||||
fun badges(): Map<String, List<Badge>> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Long, Rating> = emptyMap()): List<License?>? {
|
||||
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<Long, Rating> {
|
||||
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<Long, Rating>()
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PointF> = emptyList()
|
||||
|
||||
fun setPoints(normalized: List<PointF>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ProfileSelector's text: white when chosen, primary under the pointer, plain when empty. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_200" android:state_enabled="false" />
|
||||
<item android:color="@color/neutral_50" android:state_selected="true" />
|
||||
<item android:color="@color/primary_300" android:state_hovered="true" />
|
||||
<item android:color="@color/neutral_200" />
|
||||
</selector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The glow behind an online licence's Mii (WheelWizard blurs a Primary200 ellipse at half opacity). -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<gradient
|
||||
android:endColor="#008EFFE2"
|
||||
android:gradientRadius="130dp"
|
||||
android:startColor="#808EFFE2"
|
||||
android:type="radial" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One licence in the selector: the chosen one takes the card's colour (ProfileSelector). -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/neutral_900" android:state_selected="true" />
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The profiles page's licence selector (WheelWizard's RadioButtons border). -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_950" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The sidebar's profile card (WheelWizard's SidebarProfileBlock). -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's ArrowRight 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="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Award icon (Font Awesome, 384x512), 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="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M173.8 5.5c11-7.3 25.4-7.3 36.4 0L228 17.2c6 3.9 13 5.8 20.1 5.4l21.3-1.3c13.2-.8 25.6 6.4 31.5 18.2l9.6 19.1c3.2 6.4 8.4 11.5 14.7 14.7L344.5 83c11.8 5.9 19 18.3 18.2 31.5l-1.3 21.3c-.4 7.1 1.5 14.2 5.4 20.1l11.8 17.8c7.3 11 7.3 25.4 0 36.4L366.8 228c-3.9 6-5.8 13-5.4 20.1l1.3 21.3c.8 13.2-6.4 25.6-18.2 31.5l-19.1 9.6c-6.4 3.2-11.5 8.4-14.7 14.7L301 344.5c-5.9 11.8-18.3 19-31.5 18.2l-21.3-1.3c-7.1-.4-14.2 1.5-20.1 5.4l-17.8 11.8c-11 7.3-25.4 7.3-36.4 0L156 366.8c-6-3.9-13-5.8-20.1-5.4l-21.3 1.3c-13.2 .8-25.6-6.4-31.5-18.2l-9.6-19.1c-3.2-6.4-8.4-11.5-14.7-14.7L39.5 301c-11.8-5.9-19-18.3-18.2-31.5l1.3-21.3c.4-7.1-1.5-14.2-5.4-20.1L5.5 210.2c-7.3-11-7.3-25.4 0-36.4L17.2 156c3.9-6 5.8-13 5.4-20.1l-1.3-21.3c-.8-13.2 6.4-25.6 18.2-31.5l19.1-9.6C65 70.2 70.2 65 73.4 58.6L83 39.5c5.9-11.8 18.3-19 31.5-18.2l21.3 1.3c7.1 .4 14.2-1.5 20.1-5.4L173.8 5.5zM272 192a80 80 0 1 0 -160 0 80 80 0 1 0 160 0zM1.3 441.8L44.4 339.3c.2 .1 .3 .2 .4 .4l9.6 19.1c11.7 23.2 36 37.3 62 35.8l21.3-1.3c.2 0 .5 0 .7 .2l17.8 11.8c5.1 3.3 10.5 5.9 16.1 7.7l-37.6 89.3c-2.3 5.5-7.4 9.2-13.3 9.7s-11.6-2.2-14.8-7.2L74.4 455.5l-56.1 8.3c-5.7 .8-11.4-1.5-15-6s-4.3-10.7-2.1-16zm248 60.4L211.7 413c5.6-1.8 11-4.3 16.1-7.7l17.8-11.8c.2-.1 .4-.2 .7-.2l21.3 1.3c26 1.5 50.3-12.6 62-35.8l9.6-19.1c.1-.2 .2-.3 .4-.4l43.2 102.5c2.2 5.3 1.4 11.4-2.1 16s-9.3 6.9-15 6l-56.1-8.3-32.2 49.2c-3.2 5-8.9 7.7-14.8 7.2s-11-4.3-13.3-9.7z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Chart icon (512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M23 23v466h466v-18H41v-82.184l85.854-57.234 70.023 70.022 65.133-260.536L387.28 203.7l67.79-107.97 19.317 11.858 6.102-71.1-60.644 37.616 19.884 12.207-59.01 93.99-130.732-65.366-62.865 251.462-57.98-57.978L41 367.184V23H23z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Code icon (Font Awesome, 640x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="640"
|
||||
android:viewportHeight="640">
|
||||
<group android:translateY="64">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M392.8 1.2c-17-4.9-34.7 5-39.6 22l-128 448c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6zm80.6 120.1c-12.5 12.5-12.5 32.8 0 45.3L562.7 256l-89.4 89.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l112-112c12.5-12.5 12.5-32.8 0-45.3l-112-112c-12.5-12.5-32.8-12.5-45.3 0zm-306.7 0c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3l112 112c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256l89.4-89.4c12.5-12.5 12.5-32.8 0-45.3z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Copy icon (Font Awesome, 448x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M384 336l-192 0c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l140.1 0L400 115.9 400 320c0 8.8-7.2 16-16 16zM192 384l192 0c35.3 0 64-28.7 64-64l0-204.1c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1L192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-32-48 0 0 32c0 8.8-7.2 16-16 16L64 464c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l32 0 0-48-32 0z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's StarIcon (Font Awesome, 576x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="576"
|
||||
android:viewportHeight="576">
|
||||
<group android:translateY="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Translation icon (52x52). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="52"
|
||||
android:viewportHeight="52">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's UserCircle icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M399 384.2C376.9 345.8 335.4 320 288 320l-64 0c-47.4 0-88.9 25.8-111 64.2c35.2 39.2 86.2 63.8 143 63.8s107.8-24.7 143-63.8zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 16a72 72 0 1 0 0-144 72 72 0 1 0 0 144z" />
|
||||
</vector>
|
||||
@@ -39,6 +39,69 @@
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WheelWizard's sidebar profile block: the primary licence, opening My profiles. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/sidebar_profile"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:background="@drawable/bg_sidebar_profile"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_placeholder"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sidebar_profile_mii"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sidebar_profile_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sidebar_profile_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -61,6 +124,20 @@
|
||||
android:text="@string/launcher_nav_home" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_profiles"
|
||||
style="@style/Launcher.NavItem">
|
||||
|
||||
<ImageView
|
||||
style="@style/Launcher.NavIcon"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.NavLabel"
|
||||
android:text="@string/launcher_nav_profiles" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_patches"
|
||||
style="@style/Launcher.NavItem">
|
||||
@@ -119,6 +196,11 @@
|
||||
android:id="@+id/page_home"
|
||||
layout="@layout/page_home" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_profiles"
|
||||
layout="@layout/page_profiles"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_patches"
|
||||
layout="@layout/page_patches"
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's My profiles page (Views/Pages/UserProfilePage.axaml): the licences of Retro
|
||||
Rewind's save and what Retro WFC knows of them. The card and the carousel sit side by side on
|
||||
the headset's wide panel rather than one above the other. -->
|
||||
<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">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/profiles_title" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:text="@string/profiles_save"
|
||||
android:textColor="@color/neutral_500"
|
||||
android:textSize="12sp" />
|
||||
</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/profiles_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- The card: the four licences, and the one chosen. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_card"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:padding="10dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_slots"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:background="@drawable/bg_profile_slots"
|
||||
android:clipToOutline="true"
|
||||
android:orientation="horizontal"
|
||||
android:padding="1dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/profiles_face"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToOutline="true">
|
||||
|
||||
<View
|
||||
android:id="@+id/profiles_glow"
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="260dp"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/bg_profile_glow"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_placeholder"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle"
|
||||
android:tint="@color/neutral_700" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_mii"
|
||||
android:layout_width="176dp"
|
||||
android:layout_height="176dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profiles_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/neutral_50"
|
||||
android:textSize="24sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_code_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profiles_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_copy"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/profiles_copy_code"
|
||||
android:focusable="true"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_copy"
|
||||
android:tint="@color/neutral_500"
|
||||
android:tooltipText="@string/profiles_copy_code" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Make Primary on the left, the badges on the right, scrolling rather than
|
||||
running over it when there are many. -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="6dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_primary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="10dp"
|
||||
android:tooltipText="@string/profiles_primary_helper">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/profiles_primary_radio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:buttonTint="@color/primary_400"
|
||||
android:clickable="false"
|
||||
android:focusable="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:text="@string/profiles_make_primary"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_badges"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end|center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="4dp" />
|
||||
</HorizontalScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- The carousel: VR history, then the licence's numbers. -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1.1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_history"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_start" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_start"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_end"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/history_change" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_change"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/history_title" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/history_loading"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:indeterminateTint="@color/neutral_500"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_matches_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Switch
|
||||
android:id="@+id/history_matches"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/history_matches"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/history_days"
|
||||
android:layout_width="185dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_range"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_graph"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_row_single"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_max"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_min"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="22dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<org.wiicompiled.quest.launcher.VrHistoryChart
|
||||
android:id="@+id/history_chart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_start"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_mid"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_label_end"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/history_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"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_chart"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/history_none_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/history_empty_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="320dp"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:tooltipText="@string/profiles_vr_full">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/profiles_vr" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stats_vr"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp"
|
||||
android:tooltipText="@string/profiles_br_full">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/profiles_br" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stats_br"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginEnd="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/profiles_total_won" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stats_wins"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
style="@style/Launcher.StatCard"
|
||||
android:layout_marginStart="4dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.StatLabel"
|
||||
android:text="@string/profiles_total_played" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stats_races"
|
||||
style="@style/Launcher.StatValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_previous"
|
||||
style="@style/Launcher.CarouselArrow"
|
||||
android:contentDescription="@string/profiles_previous"
|
||||
android:src="@drawable/ic_arrow_left" />
|
||||
|
||||
<View
|
||||
android:id="@+id/profiles_dot0"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginStart="8dp" />
|
||||
|
||||
<View
|
||||
android:id="@+id/profiles_dot1"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profiles_next"
|
||||
style="@style/Launcher.CarouselArrow"
|
||||
android:contentDescription="@string/profiles_next"
|
||||
android:src="@drawable/ic_arrow_right" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profiles_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_user_circle"
|
||||
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/profiles_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/profiles_empty_message"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -16,18 +16,21 @@
|
||||
<color name="neutral_900">#363944</color>
|
||||
<color name="neutral_950">#24252D</color>
|
||||
|
||||
<color name="primary_100">#C7FFF0</color>
|
||||
<color name="primary_200">#8EFFE2</color>
|
||||
<color name="primary_300">#34EAC5</color>
|
||||
<color name="primary_400">#01CBA5</color>
|
||||
<color name="primary_600">#00A488</color>
|
||||
<color name="primary_700">#03826E</color>
|
||||
<color name="primary_800">#08675A</color>
|
||||
<color name="primary_900">#0C554A</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="warning_800">#89510A</color>
|
||||
<color name="danger_300">#FF9DAF</color>
|
||||
<color name="danger_400">#FF6581</color>
|
||||
<color name="danger_500">#FE2048</color>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<!-- Launcher shell -->
|
||||
<string name="launcher_section_general">General</string>
|
||||
<string name="launcher_nav_home">Home</string>
|
||||
<string name="launcher_nav_profiles">My profiles</string>
|
||||
<string name="launcher_nav_patches">Patches</string>
|
||||
<string name="launcher_nav_settings">Settings</string>
|
||||
<string name="launcher_version">v%1$s</string>
|
||||
@@ -130,6 +131,45 @@
|
||||
<string name="home_installing_patches">Installing mods…</string>
|
||||
<string name="home_patches_failed">The mods could not be installed</string>
|
||||
|
||||
<!-- My profiles: WheelWizard's UserProfilePage and VrHistoryGraph, wording from en.yml -->
|
||||
<string name="profiles_title">My profiles</string>
|
||||
<string name="profiles_save">Retro Rewind save</string>
|
||||
<string name="profiles_empty_title">No profiles</string>
|
||||
<string name="profiles_empty_message">You have to play Retro Rewind at least once in order to see your profiles listed here</string>
|
||||
<string name="profiles_no_license">No license</string>
|
||||
<string name="profiles_no_name">No name</string>
|
||||
<string name="profiles_make_primary">Make Primary</string>
|
||||
<string name="profiles_primary_helper">The primary profile is the one the sidebar shows.</string>
|
||||
<string name="profiles_primary_set">Set profile as primary</string>
|
||||
<string name="profiles_copy_code">Copy friend code</string>
|
||||
<string name="profiles_code_copied">Copied friend code to clipboard</string>
|
||||
<string name="profiles_code_label">Friend code</string>
|
||||
<string name="profiles_online">Online</string>
|
||||
<string name="profiles_vr">VR</string>
|
||||
<string name="profiles_vr_full">Versus Rating</string>
|
||||
<string name="profiles_br">BR</string>
|
||||
<string name="profiles_br_full">Battle Rating</string>
|
||||
<string name="profiles_total_won">Total games won</string>
|
||||
<string name="profiles_total_played">Total games played</string>
|
||||
<string name="profiles_previous">Previous</string>
|
||||
<string name="profiles_next">Next</string>
|
||||
<string name="history_start">Start VR</string>
|
||||
<string name="history_end">End VR</string>
|
||||
<string name="history_change">Net Change</string>
|
||||
<string name="history_title">VR History</string>
|
||||
<string name="history_matches">Matches</string>
|
||||
<string name="history_days_1">Last 24 hours</string>
|
||||
<string name="history_days_7">Last 7 days</string>
|
||||
<string name="history_days_30">Last 30 days</string>
|
||||
<string name="history_days_60">Last 60 days</string>
|
||||
<string name="history_days_all">Lifetime</string>
|
||||
<string name="history_range">Past %1$d days (%2$s to %3$s)</string>
|
||||
<string name="history_range_all">Lifetime history</string>
|
||||
<string name="history_match">Match %1$d</string>
|
||||
<string name="history_none_title">No VR history</string>
|
||||
<string name="history_none_range">No VR history found for this range yet.</string>
|
||||
<string name="history_no_code">Friend code is required to load VR history.</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>
|
||||
@@ -188,6 +228,7 @@
|
||||
<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_profiles">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.</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>
|
||||
@@ -202,6 +243,7 @@
|
||||
<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_profiles">My profiles</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>
|
||||
|
||||
@@ -144,6 +144,43 @@
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<!-- The profiles page's number cards (WheelWizard's profileStatCard). -->
|
||||
<style name="Launcher.StatCard">
|
||||
<item name="android:layout_width">0dp</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_weight">1</item>
|
||||
<item name="android:orientation">vertical</item>
|
||||
<item name="android:background">@drawable/bg_state_box</item>
|
||||
<item name="android:padding">10dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.StatLabel">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textColor">@color/neutral_300</item>
|
||||
<item name="android:textSize">13sp</item>
|
||||
<item name="android:fontFamily">sans-serif-medium</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.StatValue">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginTop">2dp</item>
|
||||
<item name="android:textColor">@color/neutral_100</item>
|
||||
<item name="android:textSize">17sp</item>
|
||||
</style>
|
||||
|
||||
<!-- The profiles carousel's arrows (WheelWizard's small UglyLight buttons). -->
|
||||
<style name="Launcher.CarouselArrow">
|
||||
<item name="android:layout_width">30dp</item>
|
||||
<item name="android:layout_height">30dp</item>
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:padding">8dp</item>
|
||||
<item name="android:tint">@color/neutral_300</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>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** The profiles page reads Retro WFC's answers the way WheelWizard does; shapes taken from the live API. */
|
||||
class RetroWfcTest {
|
||||
|
||||
@Test
|
||||
fun historyEntriesComeOldestFirst() {
|
||||
val json = """
|
||||
{"playerId": "601298307", "fromDate": "2026-08-24T23:31:00.6148765Z", "toDate": "2026-09-23T23:31:00.6148765Z",
|
||||
"history": [
|
||||
{"date": "2026-09-10T00:38:50.250617Z", "vrChange": 39, "totalVR": 5832},
|
||||
{"date": "2026-09-10T00:38:49.250617Z", "vrChange": 0, "totalVR": 5793},
|
||||
{"date": "not a date", "vrChange": 1, "totalVR": 1}],
|
||||
"totalVRChange": 257, "startingVR": 5793, "endingVR": 6050}
|
||||
""".trimIndent()
|
||||
val history = RetroWfc.parseHistory(json)
|
||||
assertEquals(5793, history.starting)
|
||||
assertEquals(6050, history.ending)
|
||||
assertEquals(257, history.totalChange)
|
||||
assertEquals(Instant.parse("2026-08-24T23:31:00.6148765Z").toEpochMilli(), history.from)
|
||||
assertEquals(listOf(5793, 5832), history.entries.map { it.total })
|
||||
assertEquals(listOf(0, 39), history.entries.map { it.change })
|
||||
assertEquals(1000L, history.entries[1].time - history.entries[0].time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlineIsBeingInARoomNow() {
|
||||
val json = """
|
||||
{"rooms": [
|
||||
{"id": "XESURM", "players": []},
|
||||
{"id": "A", "players": [{"pid": "166930", "friendCode": "4123-1702-7346", "mii": {"data": "", "name": "x"}},
|
||||
{"pid": "204981", "friendCode": "4939-2144-4021"}]},
|
||||
{"id": "B", "players": [{"pid": "1", "friendCode": null}]}],
|
||||
"totalPlayers": 3}
|
||||
""".trimIndent()
|
||||
assertEquals(setOf("4123-1702-7346", "4939-2144-4021"), RetroWfc.parseOnlineFriendCodes(json))
|
||||
assertEquals(emptySet<String>(), RetroWfc.parseOnlineFriendCodes("""{"rooms": null}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badgesAreListedByFriendCode() {
|
||||
val json = """
|
||||
{"all": ["WhWzDev", "RrDev", "Translator", "TranslatorLead", "Heart"],
|
||||
"4343-3434-3434": ["Firestarter_GoldWinner", "SomethingNew", "SummitShowdown_SilverWinner"]}
|
||||
""".trimIndent()
|
||||
val badges = RetroWfc.parseBadges(json)
|
||||
assertEquals(
|
||||
listOf(RetroWfc.Badge.Firestarter_GoldWinner, RetroWfc.Badge.SummitShowdown_SilverWinner),
|
||||
badges["4343-3434-3434"],
|
||||
)
|
||||
assertEquals(5, badges["all"]!!.size)
|
||||
assertNull(badges["0349-6103-6675"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aProfileCarriesItsMiiPicture() {
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 1, 2, 3)
|
||||
val json = """{"pid": "601298307", "name": "no name", "miiImageBase64": "${Base64.getEncoder().encodeToString(png)}"}"""
|
||||
assertArrayEquals(png, RetroWfc.parseMiiImage(json))
|
||||
assertNull(RetroWfc.parseMiiImage("""{"pid": "1", "miiImageBase64": null}"""))
|
||||
assertNull(RetroWfc.parseMiiImage("""{"pid": "1", "miiImageBase64": "***"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestsAreTheOnesWheelWizardMakes() {
|
||||
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675", RetroWfc.profileUrl("0349-6103-6675"))
|
||||
assertEquals("https://rwfc.net/api/leaderboard/player/0349-6103-6675/history?days=30", RetroWfc.historyUrl("0349-6103-6675", 30))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableAnswersAreIoErrors() {
|
||||
try {
|
||||
RetroWfc.parseHistory("Player with friend code '0000-0000-0001' not found")
|
||||
fail("parsed a plain-text answer")
|
||||
} catch (expected: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The profiles page reads licences as WheelWizard's GameLicenseService does. */
|
||||
class RksysProfilesTest {
|
||||
|
||||
private val licenseSize = 0x8CC0
|
||||
|
||||
/** A save with the magic and, for each given slot, an RKPD block. */
|
||||
private fun save(vararg licenses: Pair<Int, (ByteBuffer, Int) -> Unit>): ByteArray {
|
||||
val buffer = ByteBuffer.allocate(RksysProfiles.LICENSES_END + 0x100)
|
||||
buffer.put(0, "RKSD0006".toByteArray())
|
||||
for ((slot, fill) in licenses) {
|
||||
val base = 8 + slot * licenseSize
|
||||
buffer.put(base, "RKPD".toByteArray())
|
||||
fill(buffer, base)
|
||||
}
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
private fun ByteBuffer.name(base: Int, name: String) = put(base + 0x14, name.toByteArray(Charsets.UTF_16BE))
|
||||
|
||||
@Test
|
||||
fun friendCodesAreTheProfileIdWithItsChecksum() {
|
||||
// The licence on the author's headset, and a player Retro WFC listed in a room.
|
||||
assertEquals("0349-6103-6675", RksysProfiles.friendCode(0x23D71583L))
|
||||
assertEquals("4123-1702-7346", RksysProfiles.friendCode(166930L))
|
||||
assertEquals("", RksysProfiles.friendCode(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun licencesComeWithNameCodeRatingAndRaces() {
|
||||
val bytes = save(
|
||||
0 to { buffer: ByteBuffer, base: Int ->
|
||||
buffer.name(base, "Mario")
|
||||
buffer.putInt(base + 0x5C, 0x23D71583)
|
||||
buffer.putShort(base + 0xB0, 5793.toShort())
|
||||
buffer.putShort(base + 0xB2, 5052.toShort())
|
||||
buffer.putInt(base + 0xB4, 1062)
|
||||
buffer.putInt(base + 0xDC, 416)
|
||||
},
|
||||
2 to { buffer: ByteBuffer, base: Int -> buffer.name(base, "no name") },
|
||||
)
|
||||
val licenses = RksysProfiles.parse(bytes)!!
|
||||
assertEquals(4, licenses.size)
|
||||
assertNull(licenses[1])
|
||||
assertNull(licenses[3])
|
||||
assertEquals(RksysProfiles.License(0, "Mario", 0x23D71583L, "0349-6103-6675", 5793, 5052, 1062, 416), licenses[0])
|
||||
// A licence never taken online has no profile ID, so no friend code.
|
||||
assertEquals(RksysProfiles.License(2, "no name", 0, "", 0, 0, 0, 0), licenses[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retroRewindsRatingsOutrankTheSave() {
|
||||
val bytes = save(0 to { buffer: ByteBuffer, base: Int ->
|
||||
buffer.putInt(base + 0x5C, 0x23D71583)
|
||||
buffer.putShort(base + 0xB0, 5793.toShort())
|
||||
buffer.putShort(base + 0xB2, 5000.toShort())
|
||||
})
|
||||
val pul = ByteBuffer.allocate(8 + 100 * 16).apply {
|
||||
putInt(0x52525254)
|
||||
putShort(1)
|
||||
putShort(100)
|
||||
// Slot 3 holds the licence's rating; slot 4 a rating without its "has data" flag.
|
||||
putInt(8 + 3 * 16, 0x23D71583)
|
||||
putFloat(8 + 3 * 16 + 4, 60.50f)
|
||||
putFloat(8 + 3 * 16 + 8, 50.52f)
|
||||
putInt(8 + 3 * 16 + 12, 1)
|
||||
putInt(8 + 4 * 16, 42)
|
||||
putFloat(8 + 4 * 16 + 4, 99f)
|
||||
}.array()
|
||||
val ratings = RksysProfiles.parseRatings(pul)
|
||||
assertEquals(mapOf(0x23D71583L to RksysProfiles.Rating(6050, 5052)), ratings)
|
||||
val license = RksysProfiles.parse(bytes, ratings)!![0]!!
|
||||
assertEquals(6050, license.vr)
|
||||
assertEquals(5052, license.br)
|
||||
// Anything else is no rating file at all.
|
||||
assertTrue(RksysProfiles.parseRatings(ByteArray(8 + 100 * 16)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anythingElseIsNoSave() {
|
||||
assertNull(RksysProfiles.parse(ByteArray(RksysProfiles.LICENSES_END)))
|
||||
assertNull(RksysProfiles.parse("RKSD0006".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheLicencesAreRead() {
|
||||
val folder = Files.createTempDirectory("rksys").toFile()
|
||||
val bytes = save(1 to { buffer: ByteBuffer, base: Int -> buffer.name(base, "Luigi") })
|
||||
val file = File(folder, "rksys.dat").apply { writeBytes(bytes) }
|
||||
val read = RksysProfiles.readLicenses(file)!!
|
||||
assertArrayEquals(bytes.copyOf(RksysProfiles.LICENSES_END), read)
|
||||
assertEquals("Luigi", RksysProfiles.parse(read)!![1]!!.name)
|
||||
assertNull(RksysProfiles.readLicenses(File(folder, "missing.dat")))
|
||||
assertNull(RksysProfiles.readLicenses(File(folder, "short.dat").apply { writeBytes(ByteArray(64)) }))
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,24 @@ that still holds files is only cleared if the player says so. The pack's
|
||||
Riivolution XML maps that folder onto `/patches` and `/sound`. `ModLibraryTest`
|
||||
covers the rules.
|
||||
|
||||
**My profiles**, between Home and Patches, is the PC's UserProfilePage
|
||||
(`ProfilesPage`, `ProfileStore`, `RksysProfiles`, `RetroWfc`), with the PC's sidebar
|
||||
profile card above General (`SidebarProfileCard`). Like the PC it reads Retro Rewind's
|
||||
save only (`riivolution/save/RetroWFC/RMCP/rksys.dat`): the four licences with their
|
||||
name, friend code (derived from the profile ID as `FriendCodeGenerator` does), VR, BR
|
||||
and race counts, the VR and BR replaced by Pulsar's `RRRating.pul` in the NAND when it
|
||||
knows the profile, as the PC's `RRratingReader` does. Retro WFC's public API gives the
|
||||
rest: a licence is Online (card glow) while its friend code is in a room
|
||||
(`/api/roomstatus`, asked again every 30 s while the page is shown), its VR history
|
||||
comes from `/api/leaderboard/player/<fc>/history?days=N`, and its Mii picture is the
|
||||
64-pixel PNG of `/api/leaderboard/player/<fc>`, kept on disk for the sidebar. The
|
||||
headset has no Mii database (`RFL_DB.dat`), so this picture stands in for the PC's
|
||||
rendered Mii, and a licence never taken online shows a silhouette. Badges come from
|
||||
WheelWizard's `badges.json`. It only reads: Rename and change Mii need that database,
|
||||
and the PC's region picker is gone since the Quest runs PAL only. The card and the
|
||||
carousel sit side by side on the wide panel. `RksysProfilesTest` and `RetroWfcTest`
|
||||
cover the parsing.
|
||||
|
||||
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
|
||||
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,
|
||||
so values the in-headset panel wrote are kept. Each row reads its key with the
|
||||
|
||||
Reference in new issue
Block a user