From 18781f2c88214ca6a608bddd99586c35cc4103ec Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 14:33:14 +0200 Subject: [PATCH] Added Rooms in Quest Launcher - Created dialog_player_profile.xml for displaying player profiles with loading and error states. - Added item_player.xml for individual player items in lists, showing Mii, name, friend code, and VR. - Introduced item_room.xml for room items, displaying game mode, ID, and player count. - Developed page_room_details.xml for detailed room information, including player actions. - Implemented page_rooms.xml for the main rooms page layout, featuring search functionality and room listings. - Added view_vr_history.xml for visualizing player's VR history over time. - Created LiveRoomsTest.kt to validate room and player functionalities, including leaderboard integration and search capabilities. --- .../quest/launcher/LauncherActivity.kt | 44 ++- .../wiicompiled/quest/launcher/LiveRooms.kt | 291 +++++++++++++++++ .../wiicompiled/quest/launcher/MiiImages.kt | 35 +++ .../wiicompiled/quest/launcher/MiiRenderer.kt | 28 +- .../quest/launcher/MiiViewDialog.kt | 109 +++++++ .../quest/launcher/PlayerProfileDialog.kt | 94 ++++++ .../quest/launcher/ProfileStore.kt | 34 +- .../quest/launcher/ProfilesPage.kt | 201 +----------- .../wiicompiled/quest/launcher/RetroWfc.kt | 152 +++++++-- .../wiicompiled/quest/launcher/RoomsPage.kt | 293 ++++++++++++++++++ .../org/wiicompiled/quest/launcher/TapRow.kt | 14 + .../quest/launcher/VrHistoryPanel.kt | 190 ++++++++++++ .../src/main/res/drawable/bg_badge_box.xml | 6 + .../src/main/res/drawable/bg_mii_circle.xml | 5 + .../main/res/drawable/bg_state_box_danger.xml | 6 + .../main/res/drawable/bg_state_box_dark.xml | 6 + .../res/drawable/bg_state_box_success.xml | 6 + .../src/main/res/drawable/bg_top_label.xml | 6 + android/app/src/main/res/drawable/ic_info.xml | 13 + android/app/src/main/res/drawable/ic_lock.xml | 13 + .../src/main/res/drawable/ic_lock_open.xml | 13 + android/app/src/main/res/drawable/ic_road.xml | 13 + .../src/main/res/drawable/ic_road_locked.xml | 13 + .../src/main/res/drawable/ic_room_users.xml | 13 + .../src/main/res/drawable/ic_user_couple.xml | 13 + .../src/main/res/layout/activity_launcher.xml | 38 +++ .../src/main/res/layout/dialog_mii_view.xml | 50 +++ .../main/res/layout/dialog_player_profile.xml | 258 +++++++++++++++ .../app/src/main/res/layout/item_player.xml | 124 ++++++++ android/app/src/main/res/layout/item_room.xml | 71 +++++ .../app/src/main/res/layout/page_profiles.xml | 244 +-------------- .../src/main/res/layout/page_room_details.xml | 176 +++++++++++ .../app/src/main/res/layout/page_rooms.xml | 158 ++++++++++ .../src/main/res/layout/view_vr_history.xml | 245 +++++++++++++++ android/app/src/main/res/values/colors.xml | 3 + android/app/src/main/res/values/strings.xml | 60 ++++ .../quest/launcher/LiveRoomsTest.kt | 119 +++++++ .../quest/launcher/MiiRendererTest.kt | 15 + .../quest/launcher/RetroWfcTest.kt | 89 +++++- docs/quest-port.md | 35 ++- 40 files changed, 2791 insertions(+), 505 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/MiiViewDialog.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerProfileDialog.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryPanel.kt create mode 100644 android/app/src/main/res/drawable/bg_badge_box.xml create mode 100644 android/app/src/main/res/drawable/bg_mii_circle.xml create mode 100644 android/app/src/main/res/drawable/bg_state_box_danger.xml create mode 100644 android/app/src/main/res/drawable/bg_state_box_dark.xml create mode 100644 android/app/src/main/res/drawable/bg_state_box_success.xml create mode 100644 android/app/src/main/res/drawable/bg_top_label.xml create mode 100644 android/app/src/main/res/drawable/ic_info.xml create mode 100644 android/app/src/main/res/drawable/ic_lock.xml create mode 100644 android/app/src/main/res/drawable/ic_lock_open.xml create mode 100644 android/app/src/main/res/drawable/ic_road.xml create mode 100644 android/app/src/main/res/drawable/ic_road_locked.xml create mode 100644 android/app/src/main/res/drawable/ic_room_users.xml create mode 100644 android/app/src/main/res/drawable/ic_user_couple.xml create mode 100644 android/app/src/main/res/layout/dialog_mii_view.xml create mode 100644 android/app/src/main/res/layout/dialog_player_profile.xml create mode 100644 android/app/src/main/res/layout/item_player.xml create mode 100644 android/app/src/main/res/layout/item_room.xml create mode 100644 android/app/src/main/res/layout/page_room_details.xml create mode 100644 android/app/src/main/res/layout/page_rooms.xml create mode 100644 android/app/src/main/res/layout/view_vr_history.xml create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/LiveRoomsTest.kt diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 7a246a8..2dcce5d 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -30,8 +30,8 @@ import org.wiicompiled.quest.R * The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR), * with a Home page that sets up and starts the game, a 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, a My Miis page that makes and edits the game's Miis, and a Settings page that edits - * Config.toml. + * them, a My Miis page that makes and edits the game's Miis, a Settings page that edits + * Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now. * * 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 @@ -44,7 +44,7 @@ import org.wiicompiled.quest.R */ class LauncherActivity : Activity() { - private enum class Page { Home, Profiles, Patches, Miis, Settings } + private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms } /** What Home's main and secondary buttons do. */ private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } @@ -54,11 +54,14 @@ class LauncherActivity : Activity() { private lateinit var navPatches: View private lateinit var navMiis: View private lateinit var navSettings: View + private lateinit var navRooms: View + private lateinit var navRoomsCount: TextView private lateinit var homePage: View private lateinit var profilesView: View private lateinit var patchesView: View private lateinit var miisView: View private lateinit var settingsView: View + private lateinit var roomsView: View private lateinit var trails: WheelTrailsView private lateinit var playButton: View private lateinit var playIcon: ImageView @@ -77,6 +80,18 @@ class LauncherActivity : Activity() { private lateinit var miis: MiisPage private lateinit var profilesPage: ProfilesPage private lateinit var sidebarProfile: SidebarProfileCard + private lateinit var rooms: RoomsPage + + /** The sidebar's count of players online (WheelWizard's UpdatePlayerCount). */ + private val playersOnline: () -> Unit = { + val count = LiveRooms.playerCount + navRoomsCount.text = count.toString() + navRoomsCount.tooltipText = if (count == 0) { + getString(R.string.launcher_players_online_none) + } else { + resources.getQuantityString(R.plurals.launcher_players_online, count, count) + } + } /** The games this APK carries a kit for, and the one the player picked. */ private val profiles: List by lazy { GameProfile.available(this) } @@ -114,11 +129,14 @@ class LauncherActivity : Activity() { navPatches = findViewById(R.id.nav_patches) navMiis = findViewById(R.id.nav_miis) navSettings = findViewById(R.id.nav_settings) + navRooms = findViewById(R.id.nav_rooms) + navRoomsCount = findViewById(R.id.nav_rooms_count) homePage = findViewById(R.id.page_home) profilesView = findViewById(R.id.page_profiles) patchesView = findViewById(R.id.page_patches) miisView = findViewById(R.id.page_miis) settingsView = findViewById(R.id.page_settings) + roomsView = findViewById(R.id.page_rooms) trails = findViewById(R.id.home_trails) playButton = findViewById(R.id.home_play) playIcon = findViewById(R.id.home_play_icon) @@ -162,6 +180,9 @@ class LauncherActivity : Activity() { ) sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) } profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) } + rooms = RoomsPage(this, roomsView) + LiveRooms.addListener(playersOnline) + playersOnline() savedInstanceState?.getString(KEY_TAB)?.let { name -> SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) } @@ -171,6 +192,7 @@ class LauncherActivity : Activity() { navPatches.setOnClickListener { showPage(Page.Patches) } navMiis.setOnClickListener { showPage(Page.Miis) } navSettings.setOnClickListener { showPage(Page.Settings) } + navRooms.setOnClickListener { showPage(Page.Rooms) } playButton.setOnClickListener { perform(mainAction) } secondary.setOnClickListener { secondaryAction?.let(::perform) } cancel.setOnClickListener { GameSetup.cancel() } @@ -208,6 +230,7 @@ class LauncherActivity : Activity() { // so does the Mii editor on My Miis. if (page == Page.Patches && patches.back()) return if (page == Page.Miis && miis.back()) return + if (page == Page.Rooms && rooms.back()) return @Suppress("DEPRECATION") super.onBackPressed() } @@ -225,6 +248,9 @@ class LauncherActivity : Activity() { // 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) + // Who plays online is followed while the launcher is on screen, for the sidebar's count. + LiveRooms.start() + rooms.setVisible(page == Page.Rooms) // The game's process can take a moment to go away after its activity // closes, which would still read as running. val runningAtResume = isGameRunning() @@ -237,9 +263,16 @@ class LauncherActivity : Activity() { GameSetup.removeListener(setupListener) patches.detach() profilesPage.setVisible(false) + rooms.setVisible(false) + LiveRooms.stop() super.onPause() } + override fun onDestroy() { + LiveRooms.removeListener(playersOnline) + super.onDestroy() + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_PAGE, page.name) @@ -287,12 +320,15 @@ class LauncherActivity : Activity() { navPatches.isSelected = target == Page.Patches navMiis.isSelected = target == Page.Miis navSettings.isSelected = target == Page.Settings + navRooms.isSelected = target == Page.Rooms 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 miisView.visibility = if (target == Page.Miis) View.VISIBLE else View.GONE profilesPage.setVisible(target == Page.Profiles) settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE + roomsView.visibility = if (target == Page.Rooms) View.VISIBLE else View.GONE + rooms.setVisible(target == Page.Rooms) if (target == Page.Home) { trailsAway = true } @@ -306,6 +342,8 @@ class LauncherActivity : Activity() { Page.Patches -> patches.refresh() Page.Miis -> miis.refresh() Page.Settings -> settings.refresh() + // Rooms follow Retro WFC's answers by themselves. + Page.Rooms -> Unit } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt new file mode 100644 index 0000000..4c09c01 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LiveRooms.kt @@ -0,0 +1,291 @@ +package org.wiicompiled.quest.launcher + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import java.io.IOException +import java.util.concurrent.Executors + +/** + * Who plays on Retro WFC now, for the Rooms page, the sidebar's player count and the profiles' + * Online glow: WheelWizard's RRLiveRooms. While the launcher is on screen it asks every 40 seconds + * for the open rooms and the top 50 of the leaderboard, splits the rooms Retro WFC merged by + * mistake, and marks the players of the top 50 with their rank. + */ +object LiveRooms { + private const val TAG = "WiiCompiledLauncher" + private const val REFRESH_MS = 40_000L + private const val TOP_PLAYERS = 50 + /** RrLeaderboardSingletonService keeps the top players this long, and the last ones when Retro WFC fails. */ + private const val LEADERBOARD_FRESH_MS = 90_000L + + /** A player in a room (RrPlayer). Two are the same player when their profile and friend code are. */ + class Player( + val pid: String, + val name: String, + val friendCode: String, + val vr: Int?, + val br: Int?, + val isOpenHost: Boolean, + val isSuspended: Boolean, + val mii: Mii?, + /** Their place in the top 50, or null outside it. */ + val leaderboardRank: Int?, + ) { + val vrText: String get() = vr?.toString() ?: "--" + val topLabel: String get() = leaderboardRank?.let { "#$it" }.orEmpty() + + override fun equals(other: Any?): Boolean = other is Player && pid == other.pid && friendCode == other.friendCode + override fun hashCode(): Int = 31 * pid.hashCode() + friendCode.hashCode() + } + + /** A room (RrRoom), created at [created] in epoch milliseconds. */ + class Room(val id: String, val created: Long, val type: String, val rk: String?, val players: List) { + val isPublic: Boolean get() = type != "private" + val gameMode: String get() = gameMode(rk, isPublic) + + /** The players' VR averaged and truncated, as the PC shows it; 0 when none has a VR. */ + val averageVr: Int + get() = players.mapNotNull { it.vr }.takeIf { it.isNotEmpty() }?.average()?.toInt() ?: 0 + } + + enum class TimeUnit { Days, Hours, Minutes, Seconds } + + private val main by lazy { Handler(Looper.getMainLooper()) } + private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "LiveRooms").apply { isDaemon = true } } + private val listeners = LinkedHashSet<() -> Unit>() + private var running = false + private var fetching = false + + /** The rooms of the last answer, empty until one comes or when Retro WFC failed; main thread only. */ + var rooms: List = emptyList() + private set + + val playerCount: Int get() = rooms.sumOf { it.players.size } + + /** Everyone in a room, as GameLicenseService.RefreshOnlineStatus finds a licence online. */ + val onlineFriendCodes: Set get() = rooms.flatMap { room -> room.players.map { it.friendCode } }.toSet() + + // The leaderboard's cache, on the worker thread only. + private var leaderboard: List? = null + private var leaderboardAt = 0L + + private val ticker = object : Runnable { + override fun run() { + fetch() + main.postDelayed(this, REFRESH_MS) + } + } + + /** [listener] is called on the main thread after every answer, until it is removed. */ + fun addListener(listener: () -> Unit) { + listeners += listener + } + + fun removeListener(listener: () -> Unit) { + listeners -= listener + } + + /** Asks now, then every 40 seconds, while the launcher is on screen. */ + fun start() { + if (running) return + running = true + main.post(ticker) + } + + fun stop() { + running = false + main.removeCallbacks(ticker) + } + + private fun fetch() { + if (fetching) return + fetching = true + worker.execute { + val fresh = try { + build(RetroWfc.roomStatus(), topPlayers()) + } catch (e: IOException) { + Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}") + emptyList() + } + main.post { + fetching = false + rooms = fresh + listeners.toList().forEach { it() } + } + } + } + + private fun topPlayers(): List? { + val now = SystemClock.elapsedRealtime() + leaderboard?.let { if (now - leaderboardAt < LEADERBOARD_FRESH_MS) return it } + return try { + RetroWfc.topPlayers(TOP_PLAYERS).also { + leaderboard = it + leaderboardAt = now + } + } catch (e: IOException) { + Log.i(TAG, "Retro WFC's leaderboard is unavailable: ${e.message}") + leaderboard + } + } + + // What the pages show, worked out apart from Android so it can be tested. + + /** RRLiveRooms.ExecuteTaskAsync: rooms split where they were merged, their players with their Miis and ranks. */ + internal fun build(rooms: List, leaderboard: List?): List { + val byPid = leaderboard.orEmpty().filter { it.pid.isNotBlank() }.groupBy { it.pid }.mapValues { it.value.first() } + val byFriendCode = leaderboard.orEmpty().filter { it.friendCode.isNotBlank() }.groupBy { it.friendCode } + .mapValues { it.value.first() } + return splitMergedRooms(rooms).map { room -> + Room( + id = room.id, + created = room.created, + type = room.type, + rk = room.rk, + players = room.players.map { player -> + val entry = byPid[player.pid] ?: player.friendCode.takeIf { it.isNotBlank() }?.let { byFriendCode[it] } + Player( + pid = player.pid, + name = player.name, + friendCode = player.friendCode, + vr = player.vr, + br = player.br, + isOpenHost = player.isOpenHost, + isSuspended = player.isSuspended, + mii = player.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() }, + leaderboardRank = entry?.rank ?: entry?.activeRank, + ) + }, + ) + } + } + + /** + * SplitMergedRooms (after kevinvg207's rr-rooms): players only linked both ways in their + * connection maps share a room, so a room whose players fall into several such groups is + * really several rooms. A room without any link is left whole. + */ + internal fun splitMergedRooms(rooms: List): List = rooms.flatMap { room -> + val count = room.players.size + val links = List(count) { ArrayList() } + var anyLink = false + for (i in 0 until count) { + val map = room.players[i].connectionMap + if (map.isEmpty()) continue + // The map usually lists every player, the player included, but can leave them out. + val includesSelf = map.size == count + val excludesSelf = map.size == count - 1 + if (!includesSelf && !excludesSelf) continue + for (j in map.indices) { + if ((map[j].firstOrNull() ?: '0') == '0') continue + if (includesSelf && j == i) continue + val other = if (includesSelf) j else if (j >= i) j + 1 else j + if (other !in 0 until count) continue + links[i] += other + anyLink = true + } + } + if (!anyLink) return@flatMap listOf(room) + + val seen = BooleanArray(count) + val groups = ArrayList>() + for (start in 0 until count) { + if (seen[start]) continue + val stack = ArrayDeque().apply { addLast(start) } + val group = ArrayList() + while (stack.isNotEmpty()) { + val u = stack.removeLast() + if (seen[u]) continue + seen[u] = true + group += u + for (v in links[u]) if (u in links[v]) stack.addLast(v) + } + groups += group.sorted() + } + if (groups.size == 1) { + listOf(room) + } else { + groups.map { group -> RetroWfc.Room(room.id, room.type, room.created, room.rk, room.suspended, group.map { room.players[it] }) } + } + } + + /** RoomsPage.PerformSearch: every player whose name or friend code holds [query], once each. */ + fun search(rooms: List, query: String): List { + val wanted = query.trim() + if (wanted.isEmpty()) return emptyList() + return rooms.flatMap { it.players } + .filter { it.name.contains(wanted, ignoreCase = true) || it.friendCode.contains(wanted, ignoreCase = true) } + .distinct() + } + + /** tTime: a duration by its two largest units, the second left out when it is zero. */ + fun timeParts(millis: Long): List> { + val total = maxOf(0L, millis / 1000) + val days = (total / 86_400).toInt() + val hours = (total / 3_600 % 24).toInt() + val minutes = (total / 60 % 60).toInt() + val seconds = (total % 60).toInt() + fun pair(first: Pair, second: Pair) = if (second.second == 0) listOf(first) else listOf(first, second) + return when { + days >= 1 -> pair(TimeUnit.Days to days, TimeUnit.Hours to hours) + total >= 3_600 -> pair(TimeUnit.Hours to hours, TimeUnit.Minutes to minutes) + total >= 60 -> pair(TimeUnit.Minutes to minutes, TimeUnit.Seconds to seconds) + else -> listOf(TimeUnit.Seconds to seconds) + } + } + + /** RrRoom.GameMode: the name of a room's ranking, which tells the pack and the mode. */ + fun gameMode(rk: String?, isPublic: Boolean): String = GAME_MODES[rk] ?: if (isPublic) "Unknown Mode" else "Private Room" + + private val GAME_MODES = mapOf( + // Retro Rewind + "vs_10" to "RR 150CC", + "vs_11" to "RR Time Tr", + "vs_12" to "RR 200CC", + "vs_13" to "RR Item Rain", + "vs_14" to "RR Battle", + "vs_15" to "RR Elim Battle", + "vs_20" to "RR 150CC CTs", + "vs_21" to "RR Vanilla", + // CTGP + "vs_668" to "CTGP-C", + // Insane Kart Wii + "vs_69" to "Insane Kart", + "vs_70" to "Ultras VS", + "vs_71" to "Crazy Items", + "vs_72" to "Bob-omb Blast", + "vs_73" to "Inf Accel", + "vs_74" to "Banan Slip", + "vs_75" to "Rand Items", + "vs_76" to "Unfair Items", + "vs_77" to "Blue Madness", + "vs_78" to "Mush Dash", + "vs_79" to "Bumper Karts", + "vs_80" to "Item Rampage", + "vs_81" to "Item Rain", + "vs_82" to "Shell Break", + "vs_83" to "Riibalanced", + // Luminous + "vs_666" to "Luminous", + "vs_667" to "Luminous TT", + // OptPack + "vs_875" to "OP 150", + "vs_876" to "OP TT", + "vs_877" to "OP R1", + "vs_878" to "OP R2", + "vs_879" to "OP R3", + "vs_880" to "OP R4", + // WTP + "vs_1312" to "WTP 150CC", + "vs_1313" to "WTP 200CC", + "vs_1314" to "WTP Time Trial", + "vs_1315" to "WTP Item Rain", + "vs_1316" to "WTP STYD", + // Generic + "vs_751" to "Versus", + "vs_-1" to "Regular", + "vs" to "Regular", + ) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt index 6369b8c..80fcba3 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt @@ -29,6 +29,10 @@ object MiiImages { private val previewGeneration = AtomicLong() /** Changes with [clear]: a picture drawn before is shown but not kept. */ private val cacheGeneration = AtomicInteger() + private val viewer = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "MiiViewer").apply { isDaemon = true } } + private val viewGeneration = AtomicLong() + /** The newest View Mii picture shown; main thread only. */ + private var viewShown = 0L private val main by lazy { Handler(Looper.getMainLooper()) } /** @@ -102,6 +106,37 @@ object MiiImages { } } + /** + * The View Mii window's picture: the whole Mii turned as [pose], [size] pixels square, for + * [done] on the main thread (null when it cannot be drawn). Requests overtaken by newer ones + * are skipped and nothing is kept, so dragging the Mii round costs one render at a time. + */ + fun view(context: Context, mii: Mii, size: Int, pose: MiiRenderer.Pose, done: (Bitmap?) -> Unit) { + val app = context.applicationContext + val generation = viewGeneration.incrementAndGet() + val copy = mii.copy() + viewer.execute { + if (viewGeneration.get() != generation) return@execute + val bitmap = try { + MiiRenderResource.load(app)?.let { resource -> + val pixels = MiiRenderer.render(resource, copy, size, pose, MiiRenderResource.bodies(app), fullBody = true) + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) + } + } catch (e: Exception) { + Log.w(TAG, "Cannot draw a Mii picture", e) + null + } + main.post { + // Each picture is newer than the one before it: a render still running when the + // drag moved on is shown on the way. + if (generation > viewShown) { + viewShown = generation + done(bitmap) + } + } + } + } + /** Forgets every picture, for when the parts were just installed and placeholders can become Miis. */ fun clear() { cacheGeneration.incrementAndGet() diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt index b81738e..41608d2 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt @@ -35,9 +35,11 @@ object MiiRenderer { val cameraX: Float, val cameraY: Float, val cameraZ: Float, + /** The camera's distance as a share of the PC's (its CameraZoom): below 1 the Mii fills more. */ + val zoom: Float = 1f, ) { /** Tells pictures of the same Mii in different poses apart. */ - val key: String get() = "$characterX,$characterY,$characterZ,$cameraX,$cameraY,$cameraZ" + val key: String get() = "$characterX,$characterY,$characterZ,$cameraX,$cameraY,$cameraZ,$zoom" companion object { /** Straight ahead, as My Miis and the editor show a Mii. */ @@ -50,11 +52,18 @@ object MiiRenderer { /** * The Mii at [size] x [size], as non-premultiplied ARGB colours, transparent around it: the - * head, with the upper body below it when [bodies] are given. + * head, with the upper body below it when [bodies] are given, or with [fullBody] the whole Mii + * (the PC's all_body) when they are. */ @Throws(IOException::class) - fun render(resource: FflResource, mii: Mii, size: Int, pose: Pose = Pose.FRONT, bodies: MiiBodies? = null): IntArray = - render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies) + fun render( + resource: FflResource, + mii: Mii, + size: Int, + pose: Pose = Pose.FRONT, + bodies: MiiBodies? = null, + fullBody: Boolean = false, + ): IntArray = render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies, fullBody = fullBody) @Throws(IOException::class) internal fun render( @@ -64,6 +73,7 @@ object MiiRenderer { expression: Int = 0, pose: Pose = Pose.FRONT, bodies: MiiBodies? = null, + fullBody: Boolean = false, ): IntArray { require(size in 16..4096 && size % 2 == 0) { "Unsupported picture size $size" } val resolution = if (size <= 384) 256 else 512 @@ -76,8 +86,10 @@ object MiiRenderer { // The PC's face view: 15 degrees of field of view on the head, the camera orbiting it // (CalculateCameraOrbitPosition) and the head turned about its own origin. With a body // the head sits on its shoulders, and the camera rises with it; the body turns about its feet. - val y = 4.805f / 0.14f - val z = 57.553f / 0.14f + // Its all_body view stands further back, looking at the whole Mii from a fixed place. + val wholeBody = fullBody && body != null + val y = if (wholeBody) 90f else 4.805f / 0.14f + val z = (if (wholeBody) 760f else 57.553f / 0.14f) * pose.zoom val camera = radians(pose.cameraX, pose.cameraY, pose.cameraZ) val position = floatArrayOf( z * -sin(camera[1]) * cos(camera[0]), @@ -85,8 +97,8 @@ object MiiRenderer { z * cos(camera[1]) * cos(camera[0]), ) position[1] += y - val lookAt = floatArrayOf(0f, y, 0f) - if (body != null) { + val lookAt = floatArrayOf(0f, if (wholeBody) 95f else y, 0f) + if (body != null && !wholeBody) { for (c in 0 until 3) { position[c] += body.headTranslation[c] lookAt[c] += body.headTranslation[c] diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiViewDialog.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiViewDialog.kt new file mode 100644 index 0000000..1221828 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiViewDialog.kt @@ -0,0 +1,109 @@ +package org.wiicompiled.quest.launcher + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import org.wiicompiled.quest.R + +/** + * WheelWizard's MiiCarouselWindow with its Mii3DRender: the whole Mii, which dragging turns (left + * and right about the Mii, up and down about the camera) and the thumbstick brings nearer or + * further. While it moves it is drawn small, then sharp once it rests, as the PC does. + */ +class MiiViewDialog private constructor(private val activity: Activity, private val mii: Mii) { + + private val view = activity.layoutInflater.inflate(R.layout.dialog_mii_view, null) + private val image: ImageView = view.findViewById(R.id.mii_view_image) + private val loading: View = view.findViewById(R.id.mii_view_loading) + private val message: TextView = view.findViewById(R.id.mii_view_message) + private val density = activity.resources.displayMetrics.density + private val size = (PICTURE_DP * density).toInt() and 1.inv() + private val previewSize = (size / 4) and 1.inv() + private lateinit var dialog: AlertDialog + + private var yaw = 0f + private var pitch = 0f + private var zoom = 1f + private var lastX = 0f + private var lastY = 0f + private val settle = Runnable { draw(size) } + + @SuppressLint("ClickableViewAccessibility") + private fun open() { + dialog = AlertDialog.Builder(activity) + .setTitle(mii.name) + .setView(view) + .setPositiveButton(R.string.dialog_close, null) + .create() + dialog.setOnDismissListener { image.removeCallbacks(settle) } + dialog.show() + dialog.window?.setLayout(PatchesWidgets.dp(activity, 520), ViewGroup.LayoutParams.WRAP_CONTENT) + if (!MiiRenderResource.installed(activity)) { + loading.visibility = View.GONE + message.visibility = View.VISIBLE + message.setText(R.string.mii_view_no_parts) + return + } + image.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastX = event.x + lastY = event.y + } + MotionEvent.ACTION_MOVE -> { + // Mii3DRender turns 0.8 degrees per pixel dragged, in the PC's device-independent pixels. + yaw += (event.x - lastX) / density * DRAG_DEGREES + pitch += (event.y - lastY) / density * DRAG_DEGREES + lastX = event.x + lastY = event.y + draw(previewSize) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> draw(size) + } + true + } + image.setOnGenericMotionListener { _, event -> + if (event.actionMasked != MotionEvent.ACTION_SCROLL) return@setOnGenericMotionListener false + val steps = event.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (steps == 0f) return@setOnGenericMotionListener false + zoom = (zoom - steps * ZOOM_STEP).coerceIn(MIN_ZOOM, MAX_ZOOM) + draw(previewSize) + image.removeCallbacks(settle) + image.postDelayed(settle, SETTLE_MS) + true + } + draw(size) + } + + private fun draw(pixels: Int) { + if (pixels == size) image.removeCallbacks(settle) + MiiImages.view(activity, mii, pixels, MiiRenderer.Pose(0f, yaw, 0f, pitch, 0f, 0f, zoom)) { bitmap -> + if (!dialog.isShowing) return@view + loading.visibility = View.GONE + if (bitmap != null) { + image.setImageBitmap(bitmap) + } else if (image.drawable == null) { + message.visibility = View.VISIBLE + message.setText(R.string.mii_view_no_parts) + } + } + } + + companion object { + /** The picture's height in dialog_mii_view.xml. */ + private const val PICTURE_DP = 300 + private const val DRAG_DEGREES = 0.8f + private const val ZOOM_STEP = 0.1f + private const val MIN_ZOOM = 0.35f + private const val MAX_ZOOM = 1.5f + /** Mii3DRender's HighQualitySettleDelayMs. */ + private const val SETTLE_MS = 90L + + fun show(activity: Activity, mii: Mii) = MiiViewDialog(activity, mii).open() + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerProfileDialog.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerProfileDialog.kt new file mode 100644 index 0000000..f6ce97a --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerProfileDialog.kt @@ -0,0 +1,94 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.AlertDialog +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import java.text.NumberFormat +import java.util.Locale +import java.util.concurrent.Executors +import org.wiicompiled.quest.R + +/** + * WheelWizard's PlayerProfileWindow (Views/Popups/PlayerProfileWindow.axaml.cs): what Retro WFC + * knows of a player, by friend code: their Mii, rank, VR and how it moved lately, and their VR + * history. Titled with the friend code until the profile names the player. + */ +object PlayerProfileDialog { + + private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "PlayerProfile").apply { isDaemon = true } } + private val main by lazy { Handler(Looper.getMainLooper()) } + + fun show(activity: Activity, friendCode: String) { + val view = activity.layoutInflater.inflate(R.layout.dialog_player_profile, null) + val dialog = AlertDialog.Builder(activity) + .setTitle(friendCode) + .setView(view) + .setPositiveButton(R.string.dialog_close, null) + .create() + dialog.show() + dialog.window?.setLayout(PatchesWidgets.dp(activity, 640), ViewGroup.LayoutParams.WRAP_CONTENT) + val history = VrHistoryPanel(activity, view.findViewById(R.id.player_profile_history)) + worker.execute { + val result = runCatching { RetroWfc.playerProfile(friendCode) } + main.post { + if (!dialog.isShowing || activity.isDestroyed) return@post + view.findViewById(R.id.player_loading).visibility = View.GONE + val profile = result.getOrNull() + if (profile == null) { + view.findViewById(R.id.player_error).visibility = View.VISIBLE + view.findViewById(R.id.player_error_text).text = + result.exceptionOrNull()?.message ?: activity.getString(R.string.player_profile_unknown) + return@post + } + dialog.setTitle(profile.name) + fill(activity, view, profile) + history.show(profile.friendCode.ifEmpty { friendCode }) + } + } + } + + private fun fill(activity: Activity, view: View, profile: RetroWfc.PlayerProfile) { + view.findViewById(R.id.player_content).visibility = View.VISIBLE + view.findViewById(R.id.player_profile_name).text = profile.name + view.findViewById(R.id.player_profile_code).text = profile.friendCode + view.findViewById(R.id.player_profile_rank).apply { + text = activity.getString(R.string.player_profile_rank, profile.rank) + visibility = if (profile.rank > 0) View.VISIBLE else View.GONE + } + view.findViewById(R.id.player_profile_suspicious).visibility = if (profile.isSuspicious) View.VISIBLE else View.GONE + view.findViewById(R.id.player_profile_vr).text = grouped(profile.vr) + view.findViewById(R.id.player_profile_seen).text = lastSeen(activity, profile.lastSeen) + val stats = profile.vrStats + view.findViewById(R.id.player_profile_day).text = stats?.let { signed(it.last24Hours) } ?: "--" + view.findViewById(R.id.player_profile_week).text = stats?.let { signed(it.lastWeek) } ?: "--" + view.findViewById(R.id.player_profile_month).text = stats?.let { signed(it.lastMonth) } ?: "--" + + val mii = profile.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() } + val picture = view.findViewById(R.id.player_profile_mii) + val drawn = mii != null && MiiRenderResource.installed(activity) + view.findViewById(R.id.player_profile_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE + if (drawn) MiiImages.head(activity, picture, mii!!, (2 * 84 * activity.resources.displayMetrics.density).toInt() and 1.inv()) + } + + /** LastSeenText: whole days, hours or minutes ago. */ + private fun lastSeen(activity: Activity, time: Long?): String { + if (time == null) return "--" + val minutes = (System.currentTimeMillis() - time) / 60_000 + return when { + minutes >= 24 * 60 -> activity.getString(R.string.player_profile_days_ago, (minutes / (24 * 60)).toInt()) + minutes >= 60 -> activity.getString(R.string.player_profile_hours_ago, (minutes / 60).toInt()) + minutes >= 1 -> activity.getString(R.string.player_profile_minutes_ago, minutes.toInt()) + else -> activity.getString(R.string.player_profile_just_now) + } + } + + /** FormatSignedValue: a gain with its plus sign. */ + private fun signed(value: Int): String = if (value > 0) "+${grouped(value)}" else grouped(value) + + private fun grouped(value: Int): String = NumberFormat.getIntegerInstance(Locale.getDefault()).format(value) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt index 1818270..8af76ba 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt @@ -5,7 +5,6 @@ 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 @@ -16,9 +15,9 @@ import org.wiicompiled.quest.R /** * What the profiles page and the sidebar's profile card show: the licences of Retro Rewind's save * and their Miis, which one is primary, and what Retro WFC knows of them (the Miis they last played - * with, 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. What Retro WFC saw of a Mii is - * also kept on disk, so the sidebar shows it at once, even offline. + * with, their VR history), plus WheelWizard's badges; who is online comes from [LiveRooms]. Files + * are read and the network asked off the main thread, and every answer arrives on it. What Retro + * WFC saw of a Mii is also kept on disk, so the sidebar shows it at once, even offline. */ object ProfileStore { @@ -40,8 +39,6 @@ object ProfileStore { 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()) } @@ -50,8 +47,6 @@ object ProfileStore { private val remotes = HashMap() private val remotesAsked = HashSet() private val waiting = HashMap Unit>>() - private var online: Set = emptySet() - private var onlineAt = 0L private var badges: Map>? = null /** Reads Retro Rewind's save; [done] gets null when there is none, or it is not readable. */ @@ -210,29 +205,6 @@ object ProfileStore { private val NONE = ByteArray(0) - /** The friend codes in a room now, asked again when the last answer is old; the last known ones when that fails. */ - fun online(done: (Set) -> Unit) { - if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) { - done(online) - return - } - worker.execute { - val fresh = try { - RetroWfc.onlineFriendCodes() - } catch (e: IOException) { - Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}") - null - } - main.post { - if (fresh != null) { - online = fresh - onlineAt = SystemClock.elapsedRealtime() - } - done(online) - } - } - } - /** WheelWizard's badges by friend code, fetched once a session. */ fun badges(done: (Map>) -> Unit) { badges?.let { diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt index 191182c..19b0a4c 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfilesPage.kt @@ -3,28 +3,16 @@ 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 /** @@ -63,23 +51,7 @@ class ProfilesPage( private val statsPage: View = root.findViewById(R.id.profiles_stats) private val dots: List = listOf(root.findViewById(R.id.profiles_dot0), root.findViewById(R.id.profiles_dot1)) - private val historyStart: TextView = root.findViewById(R.id.history_start) - private val historyEnd: TextView = root.findViewById(R.id.history_end) - private val historyChange: TextView = root.findViewById(R.id.history_change) - private val historyRange: TextView = root.findViewById(R.id.history_range) - private val historyLoading: View = root.findViewById(R.id.history_loading) - private val historyMatches: Switch = root.findViewById(R.id.history_matches) - private val historyGraph: View = root.findViewById(R.id.history_graph) - private val historyEmpty: View = root.findViewById(R.id.history_empty) - private val historyEmptyMessage: TextView = root.findViewById(R.id.history_empty_message) - private val historyMax: TextView = root.findViewById(R.id.history_max) - private val historyMin: TextView = root.findViewById(R.id.history_min) - private val historyChart: VrHistoryChart = root.findViewById(R.id.history_chart) - private val historyLabels: List = listOf( - root.findViewById(R.id.history_label_start), - root.findViewById(R.id.history_label_mid), - root.findViewById(R.id.history_label_end), - ) + private val history = VrHistoryPanel(activity, historyPage) private val statsVr: TextView = root.findViewById(R.id.stats_vr) private val statsBr: TextView = root.findViewById(R.id.stats_br) @@ -89,23 +61,11 @@ class ProfilesPage( private var snapshot: ProfileStore.Snapshot? = null private var slot = -1 private val current: RksysProfiles.License? get() = snapshot?.licenses?.getOrNull(slot) - private var online: Set = emptySet() private var badges: Map> = emptyMap() private var carouselPage = 0 - private var days = DEFAULT_DAYS - private var history: RetroWfc.History? = null - /** The friend code and period the history on screen, or on its way, is for. */ - private var historyFor: Pair? = null - private var historyGeneration = 0 - private val main = Handler(Looper.getMainLooper()) private var visible = false - private val onlineTicker = object : Runnable { - override fun run() { - refreshOnline() - main.postDelayed(this, ONLINE_REFRESH_MS) - } - } + private val roomsChanged: () -> Unit = { renderOnline() } private val slotTabs: List = (0 until RksysProfiles.SLOTS).map { index -> TextView(activity).apply { @@ -134,38 +94,19 @@ class ProfilesPage( root.findViewById(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) } root.findViewById(R.id.profiles_next).setOnClickListener { moveCarousel(1) } - PatchesWidgets.styleSwitch(activity, historyMatches) - historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() } - root.findViewById(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() } - - root.findViewById(R.id.history_days).apply { - background = activity.getDrawable(R.drawable.bg_dropdown) - setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup)) - dropDownVerticalOffset = dp(4) - adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply { - setDropDownViewResource(R.layout.item_dropdown_popup) - } - setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false) - onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val chosen = DAY_OPTIONS[position] - if (chosen == days) return - days = chosen - current?.let(::loadHistory) - } - - override fun onNothingSelected(parent: AdapterView<*>?) = Unit - } - } renderCarousel() } - /** While the page is on screen, who is online is asked again now and then, as the PC polls its rooms. */ + /** While the page is on screen, who is online follows Retro WFC's rooms, as the PC's does. */ fun setVisible(shown: Boolean) { if (shown == visible) return visible = shown - main.removeCallbacks(onlineTicker) - if (shown) main.post(onlineTicker) + if (shown) { + LiveRooms.addListener(roomsChanged) + renderOnline() + } else { + LiveRooms.removeListener(roomsChanged) + } } /** Reads the save again, which the game may have changed since, and shows the licence chosen. */ @@ -173,8 +114,7 @@ class ProfilesPage( ProfileStore.load(activity) { loaded -> if (activity.isDestroyed) return@load snapshot = loaded - // VrHistoryGraph reloads each time it is shown: races since then count. - historyFor = null + history.forget() val licenses = loaded?.licenses.orEmpty() if (licenses.getOrNull(slot) == null) { // UserProfilePage opens on the focused user, here the primary licence. @@ -216,7 +156,7 @@ class ProfilesPage( statsBr.text = license.br.toString() statsWins.text = license.wins.toString() statsRaces.text = license.races.toString() - loadHistory(license) + history.show(license.friendCode) } /** The licence's Mii, drawn at the size it is shown at (UserProfilePage's CurrentUserSideProfile). */ @@ -238,16 +178,10 @@ class ProfilesPage( // Online, as UserProfilePage.UpdateOnlineBorders shows it. - private fun refreshOnline() { - ProfileStore.online { codes -> - online = codes - if (!activity.isDestroyed) renderOnline() - } - } - private fun renderOnline() { + if (activity.isDestroyed) return val license = current ?: return - val isOnline = license.friendCode.isNotEmpty() && license.friendCode in online + val isOnline = license.friendCode.isNotEmpty() && license.friendCode in LiveRooms.onlineFriendCodes 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 @@ -300,108 +234,6 @@ class ProfilesPage( } } - // 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)) @@ -414,12 +246,5 @@ class ProfilesPage( /** The Mii picture's frame in page_profiles.xml. */ const val PICTURE_DP = 320 const val CAROUSEL_PAGES = 2 - const val DEFAULT_DAYS = 30 - /** What VrHistoryGraph asks for "Lifetime". */ - const val LIFETIME_DAYS = 999 - val DAY_OPTIONS = listOf(1, 7, 30, 60, LIFETIME_DAYS) - val DAY_LABELS = listOf(R.string.history_days_1, R.string.history_days_7, R.string.history_days_30, R.string.history_days_60, R.string.history_days_all) - /** How often who is online is asked again while the page is shown. */ - const val ONLINE_REFRESH_MS = 30_000L } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt index 46d6f68..4761baf 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroWfc.kt @@ -13,9 +13,10 @@ 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. + * Retro WFC, the server Retro Rewind plays online on, asked what WheelWizard asks it + * (Features/RrRooms/IRwfcApi): the rooms open now, the top of the leaderboard, a player's profile, + * which carries their Mii, and their VR history. 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. @@ -60,8 +61,51 @@ object RetroWfc { Leafstruck_BronzeWinner("Leafstruck Tournament Runner-Up"), } + /** A player in an open room (RwfcRoomStatusPlayer); [mii] is the 74 bytes of their Mii. */ + class RoomPlayer( + val pid: String, + val name: String, + val friendCode: String, + val vr: Int?, + val br: Int?, + val isOpenHost: Boolean, + val isSuspended: Boolean, + val mii: ByteArray?, + val connectionMap: List, + ) + + /** An open room (RwfcRoomStatusRoom), created at [created] in epoch milliseconds. */ + class Room( + val id: String, + val type: String, + val created: Long, + val rk: String?, + val suspended: Boolean, + val players: List, + ) + + /** A row of the leaderboard (RwfcLeaderboardEntry), as far as the rooms use it. */ + class LeaderboardEntry(val pid: String, val friendCode: String, val rank: Int?, val activeRank: Int?) + + /** RwfcLeaderboardVrStats: the VR won or lost lately. */ + class VrStats(val last24Hours: Int, val lastWeek: Int, val lastMonth: Int) + + /** PlayerProfileResponse: what Retro WFC knows of a player; [mii] is the 74 bytes of their Mii. */ + class PlayerProfile( + val name: String, + val friendCode: String, + val vr: Int, + val rank: Int, + val lastSeen: Long?, + val isSuspicious: Boolean, + val vrStats: VrStats?, + val mii: ByteArray?, + ) + fun profileUrl(friendCode: String): String = "$API/leaderboard/player/${encode(friendCode)}" + fun topUrl(limit: Int): String = "$API/leaderboard/top/$limit" + fun historyUrl(friendCode: String, days: Int): String = "${profileUrl(friendCode)}/history?days=$days" private fun encode(text: String): String = URLEncoder.encode(text, "UTF-8") @@ -75,14 +119,7 @@ object RetroWfc { * The 74 bytes of the Mii a profile last played with (the Wii's RFLCharData, as the game sent * it), or null without one. */ - fun parseMiiData(json: String): ByteArray? = parsing { - val encoded = JSONObject(json).text("miiData") ?: return@parsing null - try { - Base64.getMimeDecoder().decode(encoded).takeIf { it.size >= MiiData.SIZE }?.copyOf(MiiData.SIZE) - } catch (e: IllegalArgumentException) { - null - } - } + fun parseMiiData(json: String): ByteArray? = parsing { miiBytes(JSONObject(json).text("miiData")) } /** The PNG of the player's Mii a profile carries (64 by 64 pixels), or null without one. */ fun parseMiiImage(json: String): ByteArray? = parsing { @@ -118,15 +155,79 @@ object RetroWfc { ) } - /** The friend codes of everyone in a room now (RRLiveRooms, as GameLicenseService.RefreshOnlineStatus uses it). */ - fun parseOnlineFriendCodes(json: String): Set = parsing { - val rooms = JSONObject(json).opt("rooms") as? JSONArray ?: return@parsing emptySet() - (0 until rooms.length()).flatMap { index -> - val players = (rooms.opt(index) as? JSONObject)?.opt("players") as? JSONArray ?: return@flatMap emptyList() - (0 until players.length()).mapNotNull { (players.opt(it) as? JSONObject)?.text("friendCode") } - }.toSet() + /** + * The rooms open now (RwfcRoomStatusResponse). A room or player without its ID, or a room + * without its type or creation time, which the PC could not read either, is left out. + */ + fun parseRoomStatus(json: String): List = parsing { + objects(JSONObject(json).opt("rooms")).mapNotNull { room -> + Room( + id = room.text("id") ?: return@mapNotNull null, + type = room.text("type") ?: return@mapNotNull null, + created = time(room.text("created")) ?: return@mapNotNull null, + rk = room.text("rk"), + // The PC's "suspend"; Retro WFC now calls it "isSuspended". + suspended = (room.opt("isSuspended") ?: room.opt("suspend")) as? Boolean ?: false, + players = objects(room.opt("players")).mapNotNull { player -> + // The Mii is one object, or was a list of them. + val mii = player.opt("mii").let { it as? JSONObject ?: objects(it).firstOrNull() } + RoomPlayer( + pid = player.id("pid") ?: return@mapNotNull null, + name = player.opt("name") as? String ?: "", + friendCode = player.opt("friendCode") as? String ?: "", + vr = (player.opt("vr") as? Number)?.toInt(), + br = (player.opt("br") as? Number)?.toInt(), + isOpenHost = player.opt("isOpenHost") as? Boolean ?: false, + isSuspended = player.opt("isSuspended") as? Boolean ?: false, + mii = miiBytes(mii?.text("data")), + connectionMap = (player.opt("connectionMap") as? JSONArray)?.let { map -> + (0 until map.length()).map { map.opt(it)?.toString().orEmpty() } + }.orEmpty(), + ) + }, + ) + } } + /** The top of the leaderboard, best first. */ + fun parseLeaderboard(json: String): List = parsing { + objects(JSONArray(json)).mapNotNull { entry -> + LeaderboardEntry( + pid = entry.id("pid") ?: return@mapNotNull null, + friendCode = entry.opt("friendCode") as? String ?: "", + rank = (entry.opt("rank") as? Number)?.toInt(), + activeRank = (entry.opt("activeRank") as? Number)?.toInt(), + ) + } + } + + fun parsePlayerProfile(json: String): PlayerProfile = parsing { + val root = JSONObject(json) + PlayerProfile( + name = root.opt("name") as? String ?: "", + friendCode = root.opt("friendCode") as? String ?: "", + vr = root.number("vr"), + rank = root.number("rank"), + lastSeen = time(root.text("lastSeen")), + isSuspicious = root.opt("isSuspicious") as? Boolean ?: false, + vrStats = (root.opt("vrStats") as? JSONObject)?.let { stats -> + VrStats(stats.number("last24Hours"), stats.number("lastWeek"), stats.number("lastMonth")) + }, + mii = miiBytes(root.text("miiData")), + ) + } + + /** The 74 bytes of a Mii Retro WFC sent in Base64, or null for none or anything shorter. */ + private fun miiBytes(encoded: String?): ByteArray? = try { + // The MIME decoder skips line breaks and anything else that is not Base64. + encoded?.let { Base64.getMimeDecoder().decode(it) }?.takeIf { it.size >= MiiData.SIZE }?.copyOf(MiiData.SIZE) + } catch (e: IllegalArgumentException) { + null + } + + private fun objects(value: Any?): List = + (value as? JSONArray)?.let { array -> (0 until array.length()).mapNotNull { array.opt(it) as? JSONObject } }.orEmpty() + /** WhWzDataSingletonService.LoadBadgesAsync: friend code to badges, unknown badge names left out. */ fun parseBadges(json: String): Map> = parsing { val root = JSONObject(json) @@ -153,6 +254,13 @@ object RetroWfc { 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 + /** An ID Retro WFC sends as text, or might send as a number. */ + private fun JSONObject.id(key: String): String? = when (val value = opt(key)) { + is String -> value.takeIf { it.isNotBlank() } + is Number -> value.toLong().toString() + else -> null + } + // Network; everything below blocks, so callers run it off the main thread. /** The Mii of [friendCode]'s profile, or null when Retro WFC has never seen it. */ @@ -165,7 +273,13 @@ object RetroWfc { return parseHistory(json) } - fun onlineFriendCodes(): Set = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}") + fun roomStatus(): List = parseRoomStatus(get(ROOM_STATUS_URL) ?: "{}") + + fun topPlayers(limit: Int): List = parseLeaderboard(get(topUrl(limit)) ?: "[]") + + /** What Retro WFC knows of [friendCode], or null when it has never seen them. */ + fun playerProfile(friendCode: String): PlayerProfile? = + get(profileUrl(friendCode), missingIsNull = true)?.let(::parsePlayerProfile) fun badges(): Map> = parseBadges(get(BADGES_URL) ?: "{}") diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt new file mode 100644 index 0000000..121a26e --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt @@ -0,0 +1,293 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.ArrayAdapter +import android.widget.BaseAdapter +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListPopupWindow +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import org.wiicompiled.quest.R + +/** + * The launcher's Rooms page, WheelWizard's RoomsPage and RoomDetailsPage: the rooms open on Retro + * WFC ([LiveRooms]), or with a search the players whose name or friend code holds it. A room opens + * its details in place of the list, and a player there their actions: copy their friend code, see + * their Mii, or their profile. As on the PC, nothing here joins a room. + * + * The PC's Add Friend waits for the Friends page, which reads and writes the save's friend list. + */ +class RoomsPage(private val activity: Activity, root: View) { + + private val main: View = root.findViewById(R.id.rooms_main) + private val search: EditText = root.findViewById(R.id.rooms_search) + private val empty: View = root.findViewById(R.id.rooms_empty) + private val list: View = root.findViewById(R.id.rooms_list) + private val listTitle: TextView = root.findViewById(R.id.rooms_list_title) + private val listCount: TextView = root.findViewById(R.id.rooms_list_count) + private val items: ListView = root.findViewById(R.id.rooms_items) + + private val details: View = root.findViewById(R.id.rooms_details) + private val detailsLock: View = root.findViewById(R.id.room_lock) + private val detailsId: TextView = root.findViewById(R.id.room_id) + private val detailsTime: TextView = root.findViewById(R.id.room_time) + private val detailsType: TextView = root.findViewById(R.id.room_type) + private val detailsVr: TextView = root.findViewById(R.id.room_average_vr) + private val detailsCount: TextView = root.findViewById(R.id.room_player_count) + private val detailsPlayers: ListView = root.findViewById(R.id.room_players) + + private val roomAdapter = RoomAdapter() + private val searchAdapter = PlayerAdapter { player -> roomOf(player)?.let(::openRoom) } + private val detailsAdapter = PlayerAdapter(::showActions) + + private var visible = false + private var query = "" + private var badges: Map> = emptyMap() + /** The room whose details are open, as last shown; null on the list. */ + private var detailsRoom: LiveRooms.Room? = null + private val roomsChanged: () -> Unit = { render() } + + private val miiSize = (2 * MII_DP * activity.resources.displayMetrics.density).toInt() and 1.inv() + + init { + detailsPlayers.adapter = detailsAdapter + root.findViewById(R.id.room_back).setOnClickListener { closeRoom() } + search.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(text: Editable?) { + query = text?.toString().orEmpty() + renderList() + } + }) + search.setOnEditorActionListener { view, action, _ -> + if (action != EditorInfo.IME_ACTION_SEARCH) return@setOnEditorActionListener false + activity.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(view.windowToken, 0) + true + } + } + + /** While the page is on screen it follows every answer from Retro WFC. */ + fun setVisible(shown: Boolean) { + if (shown == visible) return + visible = shown + if (!shown) { + LiveRooms.removeListener(roomsChanged) + return + } + LiveRooms.addListener(roomsChanged) + ProfileStore.badges { loaded -> + badges = loaded + if (!activity.isDestroyed) render() + } + render() + } + + /** Back closes a room's details before leaving the page. */ + fun back(): Boolean { + if (detailsRoom == null) return false + closeRoom() + return true + } + + private fun render() { + if (activity.isDestroyed) return + renderList() + renderRoom() + } + + // The list: rooms, or the players the search finds (RoomsPage.OnUpdate and PerformSearch). + + private fun renderList() { + val rooms = LiveRooms.rooms + empty.visibility = if (rooms.isEmpty()) View.VISIBLE else View.GONE + list.visibility = if (rooms.isEmpty()) View.GONE else View.VISIBLE + if (query.isBlank()) { + roomAdapter.rooms = rooms + listTitle.setText(R.string.rooms_list_rooms) + listCount.text = rooms.size.toString() + if (items.adapter !== roomAdapter) items.adapter = roomAdapter + roomAdapter.notifyDataSetChanged() + } else { + searchAdapter.players = LiveRooms.search(rooms, query) + listTitle.setText(R.string.rooms_list_players) + listCount.text = searchAdapter.players.size.toString() + if (items.adapter !== searchAdapter) items.adapter = searchAdapter + searchAdapter.notifyDataSetChanged() + } + } + + /** The room a player is in, as the PC finds it for a player picked in the search. */ + private fun roomOf(player: LiveRooms.Player): LiveRooms.Room? = LiveRooms.rooms.firstOrNull { player in it.players } + + // A room's details (RoomDetailsPage). + + private fun openRoom(room: LiveRooms.Room) { + detailsRoom = room + main.visibility = View.GONE + details.visibility = View.VISIBLE + detailsPlayers.setSelection(0) + renderRoom() + } + + private fun closeRoom() { + detailsRoom = null + details.visibility = View.GONE + main.visibility = View.VISIBLE + } + + private fun renderRoom() { + val shown = detailsRoom ?: return + // The room as Retro WFC now has it; a room it split shares its ID, so the one with the most + // players in common. A room that closed takes the page back to the list, as on the PC. + val room = LiveRooms.rooms.filter { it.id == shown.id }.maxByOrNull { candidate -> candidate.players.count { it in shown.players } } + if (room == null) { + closeRoom() + return + } + detailsRoom = room + detailsLock.visibility = if (room.isPublic) View.GONE else View.VISIBLE + detailsId.text = room.id + detailsTime.text = timeOnline(room) + detailsType.text = room.gameMode + detailsVr.text = room.averageVr.toString() + detailsCount.text = room.players.size.toString() + detailsAdapter.players = room.players + detailsAdapter.notifyDataSetChanged() + } + + /** RoomDetailsPage's context menu, opened on the player's row. */ + private fun showActions(player: LiveRooms.Player, row: View) { + val actions = listOf( + R.string.room_copy_friend_code to { copyFriendCode(player) }, + R.string.room_view_mii to { viewMii(player) }, + R.string.room_view_profile to { if (player.friendCode.isNotEmpty()) PlayerProfileDialog.show(activity, player.friendCode) }, + ) + ListPopupWindow(activity).apply { + anchorView = row + setAdapter(ArrayAdapter(activity, R.layout.item_dropdown_popup, actions.map { activity.getString(it.first) })) + setBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup)) + width = dp(220) + horizontalOffset = dp(60) + isModal = true + setOnItemClickListener { _, _, position, _ -> + dismiss() + actions[position].second() + } + show() + } + } + + private fun copyFriendCode(player: LiveRooms.Player) { + activity.getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.profiles_code_label), player.friendCode)) + Toast.makeText(activity, R.string.profiles_code_copied, Toast.LENGTH_SHORT).show() + } + + private fun viewMii(player: LiveRooms.Player) { + val mii = player.mii + if (mii == null) { + Toast.makeText(activity, R.string.room_no_mii, Toast.LENGTH_SHORT).show() + return + } + MiiViewDialog.show(activity, mii) + } + + // Rows + + /** RrRoom.TimeOnline: how long ago Retro WFC opened the room. */ + private fun timeOnline(room: LiveRooms.Room): String = + LiveRooms.timeParts(System.currentTimeMillis() - room.created).joinToString(" ") { (unit, count) -> + val plural = when (unit) { + LiveRooms.TimeUnit.Days -> R.plurals.time_days + LiveRooms.TimeUnit.Hours -> R.plurals.time_hours + LiveRooms.TimeUnit.Minutes -> R.plurals.time_minutes + LiveRooms.TimeUnit.Seconds -> R.plurals.time_seconds + } + activity.resources.getQuantityString(plural, count, count) + } + + private inner class RoomAdapter : BaseAdapter() { + var rooms: List = emptyList() + + override fun getCount(): Int = rooms.size + override fun getItem(position: Int): Any = rooms[position] + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_room, parent, false) + val room = rooms[position] + row.findViewById(R.id.room_icon).setImageResource(if (room.isPublic) R.drawable.ic_road else R.drawable.ic_road_locked) + row.findViewById(R.id.room_mode).text = room.gameMode + row.findViewById(R.id.room_code).text = room.id + row.findViewById(R.id.room_age).text = timeOnline(room) + row.findViewById(R.id.room_count).text = room.players.size.toString() + row.setOnClickListener { openRoom(room) } + return row + } + } + + /** Rows of PlayerListItem; [picked] gets the player and their row. */ + private inner class PlayerAdapter(private val picked: (LiveRooms.Player, View) -> Unit) : BaseAdapter() { + var players: List = emptyList() + + constructor(picked: (LiveRooms.Player) -> Unit) : this({ player, _ -> picked(player) }) + + override fun getCount(): Int = players.size + override fun getItem(position: Int): Any = players[position] + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val row = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_player, parent, false) + val player = players[position] + val picture = row.findViewById(R.id.player_mii) + val mii = player.mii + val drawn = mii != null && MiiRenderResource.installed(activity) + row.findViewById(R.id.player_placeholder).visibility = if (drawn) View.GONE else View.VISIBLE + if (drawn) { + MiiImages.head(activity, picture, mii!!, miiSize) + } else { + picture.setTag(R.id.mii_image_key, null) + picture.setImageDrawable(null) + } + row.findViewById(R.id.player_name).text = player.name + row.findViewById(R.id.player_code).text = player.friendCode + row.findViewById(R.id.player_vr).text = player.vrText + + val badgeList = badges[player.friendCode].orEmpty() + row.findViewById(R.id.player_badges_box).visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE + row.findViewById(R.id.player_badges).apply { + removeAllViews() + badgeList.forEachIndexed { index, badge -> + addView(BadgeView(activity, badge), LinearLayout.LayoutParams(dp(30), dp(30)).apply { if (index > 0) marginStart = dp(3) }) + } + } + row.findViewById(R.id.player_top).apply { + text = player.topLabel + visibility = if (player.leaderboardRank != null) View.VISIBLE else View.GONE + } + row.findViewById(R.id.player_host).visibility = if (player.isOpenHost) View.VISIBLE else View.GONE + row.setOnClickListener { picked(player, row) } + return row + } + } + + private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value) + + private companion object { + /** The Mii's circle in item_player.xml, drawn at twice its pixels for smooth edges. */ + const val MII_DP = 50 + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt new file mode 100644 index 0000000..fce2164 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt @@ -0,0 +1,14 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.LinearLayout + +/** + * A list row that takes every tap itself. A view with a tooltip, or a badge strip that scrolls, + * would otherwise swallow a tap on it; the pointer's hover still reaches them, so their tips show. + */ +class TapRow(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { + override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryPanel.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryPanel.kt new file mode 100644 index 0000000..80bd5d7 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/VrHistoryPanel.kt @@ -0,0 +1,190 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.graphics.PointF +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.ProgressBar +import android.widget.Spinner +import android.widget.Switch +import android.widget.TextView +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 + +/** + * WheelWizard's VrHistoryGraph (Views/Patterns/VrHistoryGraph): a player's VR over the period + * chosen, with its start, end and change, drawn by time or by match. [root] is a view_vr_history + * layout; My profiles shows its licence's, the Rooms page a player's. + */ +class VrHistoryPanel(private val activity: Activity, root: View) { + + private val start: TextView = root.findViewById(R.id.history_start) + private val end: TextView = root.findViewById(R.id.history_end) + private val change: TextView = root.findViewById(R.id.history_change) + private val range: TextView = root.findViewById(R.id.history_range) + private val loading: ProgressBar = root.findViewById(R.id.history_loading) + private val matches: Switch = root.findViewById(R.id.history_matches) + private val graph: View = root.findViewById(R.id.history_graph) + private val empty: View = root.findViewById(R.id.history_empty) + private val emptyMessage: TextView = root.findViewById(R.id.history_empty_message) + private val max: TextView = root.findViewById(R.id.history_max) + private val min: TextView = root.findViewById(R.id.history_min) + private val chart: VrHistoryChart = root.findViewById(R.id.history_chart) + private val labels: List = listOf( + root.findViewById(R.id.history_label_start), + root.findViewById(R.id.history_label_mid), + root.findViewById(R.id.history_label_end), + ) + + private var days = DEFAULT_DAYS + private var friendCode: String? = null + private var history: RetroWfc.History? = null + /** The friend code and period the history on screen, or on its way, is for. */ + private var shownFor: Pair? = null + private var generation = 0 + + init { + PatchesWidgets.styleSwitch(activity, matches) + matches.setOnCheckedChangeListener { _, _ -> render() } + root.findViewById(R.id.history_matches_row).setOnClickListener { matches.toggle() } + + root.findViewById(R.id.history_days).apply { + background = activity.getDrawable(R.drawable.bg_dropdown) + setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup)) + dropDownVerticalOffset = PatchesWidgets.dp(activity, 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 + friendCode?.let(::show) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + } + + /** Shows [code]'s history, loading it unless it is already the one shown for this period. */ + fun show(code: String) { + friendCode = code + if (shownFor == code to days) return + shownFor = code to days + val loadGeneration = ++generation + history = null + if (code.isEmpty() || code.none { it in '1'..'9' }) { + // SetNoFriendCodeState: a licence never taken online has nothing to show. + loading.visibility = View.GONE + showState(activity.getString(R.string.history_no_code)) + range.text = "" + return + } + loading.visibility = View.VISIBLE + ProfileStore.history(code, days) { result -> + if (loadGeneration != generation || activity.isDestroyed) return@history + loading.visibility = View.GONE + result.onSuccess { loaded -> + history = loaded + render() + }.onFailure { failure -> + range.text = "" + showState(failure.message ?: failure.toString()) + } + } + } + + /** VrHistoryGraph reloads each time it is shown: races since then count. */ + fun forget() { + shownFor = null + } + + /** VrHistoryGraph.ApplyHistoryData: the totals, the range, then the plot by time or by match. */ + private fun render() { + val data = history ?: return + start.text = grouped(data.starting) + end.text = grouped(data.ending) + change.text = if (data.totalChange > 0) "+${grouped(data.totalChange)}" else grouped(data.totalChange) + change.setTextColor( + activity.getColor( + when { + data.totalChange > 0 -> R.color.primary_400 + data.totalChange < 0 -> R.color.danger_400 + else -> R.color.neutral_100 + }, + ), + ) + range.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()) { + showState(activity.getString(R.string.history_none_range)) + return + } + graph.visibility = View.VISIBLE + empty.visibility = View.GONE + val first = entries.first().time + val span = maxOf(1_000L, entries.last().time - first) + val lowest = entries.minOf { it.total } + val highest = entries.maxOf { it.total } + val vrRange = maxOf(1, highest - lowest).toFloat() + max.text = grouped(highest) + min.text = grouped(lowest) + val byMatch = matches.isChecked + val texts = 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(first, first + span / 2, entries.last().time).map { date(it, pattern) } + } + labels.zip(texts).forEach { (view, text) -> view.text = text } + chart.setPoints( + entries.mapIndexed { index, entry -> + val x = if (byMatch) { + if (entries.size > 1) index.toFloat() / (entries.size - 1) else 0f + } else { + (entry.time - first).toFloat() / span + } + PointF(x, (entry.total - lowest) / vrRange) + }, + ) + } + + /** VrHistoryGraph's empty and error states, which share one look. */ + private fun showState(message: String) { + graph.visibility = View.GONE + empty.visibility = View.VISIBLE + emptyMessage.text = message + for (view in listOf(start, end, change)) view.text = "0" + change.setTextColor(activity.getColor(R.color.neutral_100)) + chart.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 companion object { + 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) + } +} diff --git a/android/app/src/main/res/drawable/bg_badge_box.xml b/android/app/src/main/res/drawable/bg_badge_box.xml new file mode 100644 index 0000000..a977976 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_badge_box.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_mii_circle.xml b/android/app/src/main/res/drawable/bg_mii_circle.xml new file mode 100644 index 0000000..26f6996 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_mii_circle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_state_box_danger.xml b/android/app/src/main/res/drawable/bg_state_box_danger.xml new file mode 100644 index 0000000..ac553f0 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_state_box_danger.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_state_box_dark.xml b/android/app/src/main/res/drawable/bg_state_box_dark.xml new file mode 100644 index 0000000..387b955 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_state_box_dark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_state_box_success.xml b/android/app/src/main/res/drawable/bg_state_box_success.xml new file mode 100644 index 0000000..664d5a3 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_state_box_success.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_top_label.xml b/android/app/src/main/res/drawable/bg_top_label.xml new file mode 100644 index 0000000..d9635cb --- /dev/null +++ b/android/app/src/main/res/drawable/bg_top_label.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_info.xml b/android/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..0e3fd87 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_lock.xml b/android/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..99c8b5b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_lock_open.xml b/android/app/src/main/res/drawable/ic_lock_open.xml new file mode 100644 index 0000000..80f2c6e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_lock_open.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_road.xml b/android/app/src/main/res/drawable/ic_road.xml new file mode 100644 index 0000000..653dd49 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_road.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_road_locked.xml b/android/app/src/main/res/drawable/ic_road_locked.xml new file mode 100644 index 0000000..1cc7c93 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_road_locked.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_room_users.xml b/android/app/src/main/res/drawable/ic_room_users.xml new file mode 100644 index 0000000..60a589f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_room_users.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_user_couple.xml b/android/app/src/main/res/drawable/ic_user_couple.xml new file mode 100644 index 0000000..6620f8e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_user_couple.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 02bc462..abd2bb0 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -180,6 +180,39 @@ android:text="@string/launcher_nav_settings" /> + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_mii_view.xml b/android/app/src/main/res/layout/dialog_mii_view.xml new file mode 100644 index 0000000..e5d4ef4 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_mii_view.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_player_profile.xml b/android/app/src/main/res/layout/dialog_player_profile.xml new file mode 100644 index 0000000..d8ca358 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_player_profile.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_player.xml b/android/app/src/main/res/layout/item_player.xml new file mode 100644 index 0000000..f6cea60 --- /dev/null +++ b/android/app/src/main/res/layout/item_player.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_room.xml b/android/app/src/main/res/layout/item_room.xml new file mode 100644 index 0000000..f5ad224 --- /dev/null +++ b/android/app/src/main/res/layout/item_room.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_profiles.xml b/android/app/src/main/res/layout/page_profiles.xml index ecb43f6..4d25fef 100644 --- a/android/app/src/main/res/layout/page_profiles.xml +++ b/android/app/src/main/res/layout/page_profiles.xml @@ -220,249 +220,11 @@ android:layout_height="0dp" android:layout_weight="1"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_rooms.xml b/android/app/src/main/res/layout/page_rooms.xml new file mode 100644 index 0000000..3ec7428 --- /dev/null +++ b/android/app/src/main/res/layout/page_rooms.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/view_vr_history.xml b/android/app/src/main/res/layout/view_vr_history.xml new file mode 100644 index 0000000..4eb651c --- /dev/null +++ b/android/app/src/main/res/layout/view_vr_history.xml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 753f499..a4b263e 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -31,9 +31,12 @@ #D19200 #A66802 #89510A + #FFC5CF #FF9DAF #FF6581 #FE2048 + #EC163D + #C70E2F #173726 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index baa4041..7a55ea1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -11,6 +11,13 @@ Patches My Miis Settings + Online + Rooms + There are currently no players online + + There is currently %d player online + There are currently %d players online + v%1$s @@ -158,6 +165,59 @@ End VR Net Change VR History + + + Please note that this page does not offer any functionality to join a room directly. To join a specific room, you\'ll need to either join through a friend, or hope to join it by joining the online servers. + Search For Players... + No rooms found + You might not have internet connection, or no-one might be playing. + Rooms + Players + Time Online + Private room + Room Details + ID + Type + Average VR + VR + Versus Rating + Open Host + Copy Friend Code + View Mii + View Profile + This player has no valid Mii data. + + %d day + %d days + + + %d hour + %d hours + + + %d minute + %d minutes + + + %d second + %d seconds + + Player Profile + Loading profile... + Retro WFC does not know this player. + Rank #%1$d + Suspicious + Last Seen + VR (24h) + VR (Week) + VR (Month) + %1$d day(s) ago + %1$d hour(s) ago + %1$d minute(s) ago + Just now + Drag to turn the Mii; the thumbstick zooms. + Download the Mii parts on My Miis to see this Mii here. + Close Matches Last 24 hours Last 7 days diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/LiveRoomsTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/LiveRoomsTest.kt new file mode 100644 index 0000000..80eea00 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/LiveRoomsTest.kt @@ -0,0 +1,119 @@ +package org.wiicompiled.quest.launcher + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The rooms are worked out from Retro WFC's answers as WheelWizard's RRLiveRooms and RrRoom do. */ +class LiveRoomsTest { + + private fun player(pid: String, friendCode: String = "", name: String = pid, vr: Int? = null, map: List = emptyList(), mii: ByteArray? = null) = + RetroWfc.RoomPlayer(pid, name, friendCode, vr, null, isOpenHost = false, isSuspended = false, mii = mii, connectionMap = map) + + private fun room(id: String, vararg players: RetroWfc.RoomPlayer, type: String = "anybody", rk: String? = "vs_10") = + RetroWfc.Room(id, type, created = 0L, rk = rk, suspended = false, players = players.toList()) + + @Test + fun roomsRetroWfcMergedAreSplitWhereTheirPlayersAreNotLinked() { + // Two pairs linked both ways, the map listing every player, each player included. + val merged = room( + "MERGED", + player("a", map = listOf("0", "1", "0", "0")), + player("b", map = listOf("1", "0", "0", "0")), + // c points at a, but a does not point back: no link. + player("c", map = listOf("1", "0", "0", "1")), + player("d", map = listOf("0", "0", "1", "0")), + ) + val split = LiveRooms.splitMergedRooms(listOf(merged)) + assertEquals(listOf(listOf("a", "b"), listOf("c", "d")), split.map { r -> r.players.map { it.pid } }) + assertEquals(listOf("MERGED", "MERGED"), split.map { it.id }) + + // A map can leave the player out: b's first entry is a, a's first is b. + val withoutSelf = room( + "THREE", + player("a", map = listOf("1", "0")), + player("b", map = listOf("1", "0")), + player("c", map = listOf("0", "0")), + ) + assertEquals(listOf(listOf("a", "b"), listOf("c")), LiveRooms.splitMergedRooms(listOf(withoutSelf)).map { r -> r.players.map { it.pid } }) + + // Without any link, or all linked, a room stays whole. + val unlinked = room("WHOLE", player("a"), player("b")) + assertEquals(listOf(unlinked), LiveRooms.splitMergedRooms(listOf(unlinked))) + val linked = room("PAIR", player("a", map = listOf("0", "1")), player("b", map = listOf("1", "0"))) + assertEquals(listOf(linked), LiveRooms.splitMergedRooms(listOf(linked))) + } + + @Test + fun playersCarryTheirLeaderboardPlaceAndMii() { + val mii = MiiFactory.male("Racer") + val rooms = listOf( + room( + "ROOM", + player("1", "1111-1111-1111", vr = 9000, mii = MiiData.serialize(mii)), + player("2", "2222-2222-2222", vr = 5001, mii = ByteArray(MiiData.SIZE) { 0xFF.toByte() }), + player("3", "3333-3333-3333"), + ), + ) + val leaderboard = listOf( + RetroWfc.LeaderboardEntry("1", "", rank = 4, activeRank = null), + // Found by friend code, placed by its active rank. + RetroWfc.LeaderboardEntry("99", "2222-2222-2222", rank = null, activeRank = 12), + ) + val (first, second, third) = LiveRooms.build(rooms, leaderboard).single().players + assertEquals(4, first.leaderboardRank) + assertEquals("#4", first.topLabel) + assertEquals(mii, first.mii) + assertEquals(12, second.leaderboardRank) + // Bytes the PC cannot read as a Mii are no Mii. + assertNull(second.mii) + assertNull(third.leaderboardRank) + assertEquals("", third.topLabel) + assertEquals("--", third.vrText) + // Without the leaderboard, nobody has a place. + assertEquals(listOf(null, null, null), LiveRooms.build(rooms, null).single().players.map { it.leaderboardRank }) + // The average leaves out players without a VR and drops the fraction. + assertEquals(7000, LiveRooms.build(rooms, null).single().averageVr) + assertEquals(0, LiveRooms.build(listOf(room("EMPTY")), null).single().averageVr) + } + + @Test + fun searchFindsNamesAndFriendCodesOnce() { + val rooms = LiveRooms.build( + listOf( + room("A", player("1", "1111-2222-3333", name = "Mario"), player("2", "4444-5555-6666", name = "Luigi")), + room("B", player("1", "1111-2222-3333", name = "Mario"), player("3", "7777-8888-9999", name = "Peach")), + ), + null, + ) + assertEquals(listOf("Mario"), LiveRooms.search(rooms, "mar").map { it.name }) + assertEquals(listOf("Luigi"), LiveRooms.search(rooms, "5555").map { it.name }) + assertEquals(emptyList(), LiveRooms.search(rooms, " ")) + } + + @Test + fun timeOnlineIsItsTwoLargestUnits() { + fun parts(seconds: Long) = LiveRooms.timeParts(seconds * 1000) + assertEquals(listOf(LiveRooms.TimeUnit.Seconds to 45), parts(45)) + assertEquals(listOf(LiveRooms.TimeUnit.Minutes to 1, LiveRooms.TimeUnit.Seconds to 30), parts(90)) + assertEquals(listOf(LiveRooms.TimeUnit.Hours to 1), parts(3_600)) + assertEquals(listOf(LiveRooms.TimeUnit.Hours to 23, LiveRooms.TimeUnit.Minutes to 59), parts(86_399)) + // A day and five minutes shows the day alone: its hours are zero. + assertEquals(listOf(LiveRooms.TimeUnit.Days to 1), parts(86_700)) + assertEquals(listOf(LiveRooms.TimeUnit.Days to 2, LiveRooms.TimeUnit.Hours to 3), parts(2 * 86_400 + 3 * 3_600 + 59)) + // A room from a clock ahead of the headset's has just opened. + assertEquals(listOf(LiveRooms.TimeUnit.Seconds to 0), parts(-5)) + } + + @Test + fun gameModesAreThePcs() { + assertEquals("RR 150CC", LiveRooms.gameMode("vs_10", isPublic = true)) + assertEquals("RR Elim Battle", LiveRooms.gameMode("vs_15", isPublic = true)) + assertEquals("Regular", LiveRooms.gameMode("vs", isPublic = true)) + assertEquals("Unknown Mode", LiveRooms.gameMode("unknown", isPublic = true)) + assertEquals("Private Room", LiveRooms.gameMode(null, isPublic = false)) + val private = LiveRooms.build(listOf(room("P", type = "private", rk = "unknown")), null).single() + assertEquals(false, private.isPublic) + assertEquals("Private Room", private.gameMode) + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt index c958016..b693459 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/MiiRendererTest.kt @@ -187,6 +187,21 @@ class MiiRendererTest { assertTrue(MiiRenderer.render(resource(), girl, 64, bodies = bodies()).contentEquals(MiiRenderer.render(resource(), girl, 64))) } + @Test + fun theWholeMiiIsSeenFromFurtherBack() { + val mii = MiiFactory.male("Square").apply { faceShape = 5 } + val face = MiiRenderer.render(resource(), mii, 64, bodies = bodies()) + val whole = MiiRenderer.render(resource(), mii, 64, bodies = bodies(), fullBody = true) + // From further back the Mii is smaller, all of it in the picture... + assertTrue(!whole.contentEquals(face)) + assertTrue(opaqueIn(whole, 0 until 64) < opaqueIn(face, 0 until 64)) + // ...and without a body there is no whole Mii to show: the face view stands in. + assertTrue(MiiRenderer.render(resource(), mii, 64, fullBody = true).contentEquals(MiiRenderer.render(resource(), mii, 64))) + // Nearer is bigger. + val near = MiiRenderer.render(resource(), mii, 64, MiiRenderer.Pose(0f, 0f, 0f, 0f, 0f, 0f, zoom = 0.5f), bodies(), fullBody = true) + assertTrue(opaqueIn(near, 0 until 64) > opaqueIn(whole, 0 until 64)) + } + @Test fun aMiiWithoutItsFacelineCannotBeDrawn() { val mii = MiiFactory.male("Missing").apply { faceShape = 1 } diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt index 2f16259..588b755 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroWfcTest.kt @@ -33,17 +33,89 @@ class RetroWfcTest { } @Test - fun onlineIsBeingInARoomNow() { + fun roomsAreReadWithTheirPlayersAndMiis() { + val mii = MiiData.serialize(MiiFactory.male("Racer").apply { miiId = 0x80000001L }) 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} + {"id": "EVFAQV", "type": "anybody", "created": "2026-09-23T22:48:15.1641988Z", "host": "29", "rk": "vs_10", + "players": [ + {"pid": "166930", "name": "Racer", "friendCode": "1111-2222-3333", "vr": 13967, "br": 9999, "isOpenHost": true, + "isSuspended": false, "mii": {"data": "${Base64.getEncoder().encodeToString(mii)}", "name": "Racer"}, + "connectionMap": ["0", "1"], "slotId": "29"}, + {"pid": 204981, "name": "Other", "friendCode": "4444-5555-6666", "vr": null, "mii": [{"data": "AAEC"}], + "connectionMap": []}], + "averageVR": 13967, "race": {"num": 10, "course": 324, "cc": 2, "trackName": "GCN Wario Colosseum"}, + "roomType": "Retro Tracks", "isPublic": true, "isJoinable": false, "isSuspended": true}, + {"id": "XESURM", "type": "private", "created": "2026-09-13T18:56:58Z", "rk": "unknown", "players": [], "suspend": false}, + {"type": "anybody", "created": "2026-09-13T18:56:58Z", "players": []}, + {"id": "BADDAY", "type": "anybody", "created": "not a date", "players": []}], + "timestamp": "2026-09-23T23:22:18.9478744Z", "totalPlayers": 2} """.trimIndent() - assertEquals(setOf("4123-1702-7346", "4939-2144-4021"), RetroWfc.parseOnlineFriendCodes(json)) - assertEquals(emptySet(), RetroWfc.parseOnlineFriendCodes("""{"rooms": null}""")) + val rooms = RetroWfc.parseRoomStatus(json) + // Rooms without their ID or creation time are left out, as the PC could not read them. + assertEquals(listOf("EVFAQV", "XESURM"), rooms.map { it.id }) + val room = rooms[0] + assertEquals("anybody", room.type) + assertEquals(Instant.parse("2026-09-23T22:48:15.1641988Z").toEpochMilli(), room.created) + assertEquals("vs_10", room.rk) + assertEquals(true, room.suspended) + val (racer, other) = room.players + assertEquals("166930", racer.pid) + assertEquals("Racer", racer.name) + assertEquals("1111-2222-3333", racer.friendCode) + assertEquals(13967, racer.vr) + assertEquals(9999, racer.br) + assertEquals(true, racer.isOpenHost) + assertArrayEquals(mii, racer.mii) + assertEquals(listOf("0", "1"), racer.connectionMap) + // A numeric ID, no VR, and a Mii too short to be one. + assertEquals("204981", other.pid) + assertNull(other.vr) + assertNull(other.br) + assertNull(other.mii) + assertEquals(false, rooms[1].suspended) + assertEquals(emptyList(), RetroWfc.parseRoomStatus("""{"rooms": null}""")) + } + + @Test + fun theLeaderboardGivesRanksByProfileAndFriendCode() { + val json = """ + [{"pid": "592986326", "name": "s", "friendCode": "1111-2222-3333", "vr": 161570, "rank": 1, "isSuspicious": false}, + {"pid": "2", "friendCode": "", "rank": null, "activeRank": 7}, + {"name": "no pid", "rank": 3}] + """.trimIndent() + val entries = RetroWfc.parseLeaderboard(json) + assertEquals(listOf("592986326", "2"), entries.map { it.pid }) + assertEquals(1, entries[0].rank) + assertEquals("1111-2222-3333", entries[0].friendCode) + assertNull(entries[1].rank) + assertEquals(7, entries[1].activeRank) + } + + @Test + fun aPlayersProfileIsReadAsThePcReadsIt() { + val mii = MiiData.serialize(MiiFactory.female("Profile")) + val json = """ + {"pid": "601298307", "name": "Profile", "friendCode": "1111-2222-3333", "vr": 6050, "rank": 38439, + "lastSeen": "2026-09-21T02:43:42.584862Z", "isSuspicious": true, + "vrStats": {"last24Hours": 0, "lastWeek": -4967, "lastMonth": 257}, + "miiImageBase64": null, "miiData": "${Base64.getEncoder().encodeToString(mii)}", "badges": null} + """.trimIndent() + val profile = RetroWfc.parsePlayerProfile(json) + assertEquals("Profile", profile.name) + assertEquals("1111-2222-3333", profile.friendCode) + assertEquals(6050, profile.vr) + assertEquals(38439, profile.rank) + assertEquals(Instant.parse("2026-09-21T02:43:42.584862Z").toEpochMilli(), profile.lastSeen) + assertEquals(true, profile.isSuspicious) + assertEquals(-4967, profile.vrStats!!.lastWeek) + assertEquals(257, profile.vrStats!!.lastMonth) + assertArrayEquals(mii, profile.mii) + // A profile without stats or a Mii still reads. + val bare = RetroWfc.parsePlayerProfile("""{"name": "x", "friendCode": "1111-2222-3333"}""") + assertNull(bare.vrStats) + assertNull(bare.mii) + assertNull(bare.lastSeen) } @Test @@ -87,6 +159,7 @@ class RetroWfcTest { 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)) + assertEquals("https://rwfc.net/api/leaderboard/top/50", RetroWfc.topUrl(50)) } @Test diff --git a/docs/quest-port.md b/docs/quest-port.md index dee264c..23292ec 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -227,9 +227,8 @@ save only (`riivolution/save/RetroWFC/RMCP/rksys.dat`): the four licences with t 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//history?days=N`, and +rest: a licence is Online (card glow) while its friend code is in a room of the Rooms +page's live rooms (below), its VR history comes from `/api/leaderboard/player//history?days=N`, and `/api/leaderboard/player/` gives the Mii it last played with, as the 74-byte `miiData` and a 64-pixel PNG, kept on disk. The Mii is drawn as the PC draws it, turned three-quarters (`CurrentUserSideProfile`) by My Miis' renderer (below): the @@ -278,10 +277,32 @@ three-quarter ones differed in at most 32 edge pixels of 160,000. One deliberate fix: the PC colours a beard with the hair colour, here with the facial hair colour, as the Wii does. The editor's choices are drawn from the same parts, the flat parts from their textures in the Mii's colours and hairstyles, head shapes and beards as -the Mii's head without its body, where the PC shows icons of its own. On a Quest 3 the 424-pixel face in the -editor takes about 150 ms, drawn in four bands of rows in parallel, and a choice -about 40 ms. `MiiDataTest`, `MiiDatabaseTest`, `MiiIdsTest`, `MiiBodiesTest` and -`MiiRendererTest` (on made-up parts and body files) cover it. +the Mii's head without its body, where the PC shows icons of its own. On a Quest 3 +the 424-pixel face in the editor takes about 150 ms, drawn in four bands of rows in +parallel, and a choice about 40 ms. `MiiDataTest`, `MiiDatabaseTest`, `MiiIdsTest`, +`MiiBodiesTest` and `MiiRendererTest` (on made-up parts and body files) cover it. + +**Rooms**, under Online after Settings, is the PC's RoomsPage and RoomDetailsPage +(`RoomsPage`), fed by `LiveRooms`, the PC's RRLiveRooms: while the launcher is on +screen it asks Retro WFC every 40 s for `/api/roomstatus` and the top 50 of +`/api/leaderboard/top/50` (kept 90 s, the last ones reused when it fails), splits +the rooms Retro WFC merged by mistake where their players' connection maps are not +linked both ways (the PC's SplitMergedRooms, checked against it on a real answer), +and marks top-50 players with their place. The same answers drive the sidebar's +player count and the profiles' Online glow. The room status is read as Retro WFC +sends it now (`isSuspended`, a Mii object of `data` and `name`) as well as the PC's +older fields. The page lists rooms with their game mode (the PC's `rk` names), ID, +time online and player count, or with a search the players whose name or friend +code holds it; a room opens its details (ID, time online, mode, average VR, and its +players with their Mii, VR, badges, place and open host), and a player there Copy +Friend Code, View Mii and View Profile. View Mii is the PC's MiiCarouselWindow: the +whole Mii in the renderer's `all_body` view, turned by dragging and zoomed with the +thumbstick, small while it moves and sharp once it rests. View Profile is the PC's +PlayerProfileWindow, with the VR history of My profiles (`VrHistoryPanel`, one +`view_vr_history` layout for both). The PC's Add Friend waits for the Friends page, +which reads and writes the save's friend list. Rows are `TapRow`s, which take every +tap so a tooltip or the badge strip inside cannot swallow one, while the pointer's +hover still shows their tips. `LiveRoomsTest` and `RetroWfcTest` cover it. The launcher follows the runtime's rules exactly. `TomlConfig` edits one line the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,