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.
This commit is contained in:
iChris4 committed 2026-09-24 14:33:14 +02:00
1 parent bff6d36bad
commit 18781f2c88
40 files changed
+2791 -505

No files matched your search

@@ -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<GameProfile> 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
}
}
@@ -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<Player>) {
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<Room> = 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<String> get() = rooms.flatMap { room -> room.players.map { it.friendCode } }.toSet()
// The leaderboard's cache, on the worker thread only.
private var leaderboard: List<RetroWfc.LeaderboardEntry>? = 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<RetroWfc.LeaderboardEntry>? {
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<RetroWfc.Room>, leaderboard: List<RetroWfc.LeaderboardEntry>?): List<Room> {
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<RetroWfc.Room>): List<RetroWfc.Room> = rooms.flatMap { room ->
val count = room.players.size
val links = List(count) { ArrayList<Int>() }
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<List<Int>>()
for (start in 0 until count) {
if (seen[start]) continue
val stack = ArrayDeque<Int>().apply { addLast(start) }
val group = ArrayList<Int>()
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<Room>, query: String): List<Player> {
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<Pair<TimeUnit, Int>> {
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<TimeUnit, Int>, second: Pair<TimeUnit, Int>) = 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",
)
}
@@ -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()
@@ -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]
@@ -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()
}
}
@@ -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<View>(R.id.player_loading).visibility = View.GONE
val profile = result.getOrNull()
if (profile == null) {
view.findViewById<View>(R.id.player_error).visibility = View.VISIBLE
view.findViewById<TextView>(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<View>(R.id.player_content).visibility = View.VISIBLE
view.findViewById<TextView>(R.id.player_profile_name).text = profile.name
view.findViewById<TextView>(R.id.player_profile_code).text = profile.friendCode
view.findViewById<TextView>(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<View>(R.id.player_profile_suspicious).visibility = if (profile.isSuspicious) View.VISIBLE else View.GONE
view.findViewById<TextView>(R.id.player_profile_vr).text = grouped(profile.vr)
view.findViewById<TextView>(R.id.player_profile_seen).text = lastSeen(activity, profile.lastSeen)
val stats = profile.vrStats
view.findViewById<TextView>(R.id.player_profile_day).text = stats?.let { signed(it.last24Hours) } ?: "--"
view.findViewById<TextView>(R.id.player_profile_week).text = stats?.let { signed(it.lastWeek) } ?: "--"
view.findViewById<TextView>(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<ImageView>(R.id.player_profile_mii)
val drawn = mii != null && MiiRenderResource.installed(activity)
view.findViewById<View>(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)
}
@@ -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<String, Remote>()
private val remotesAsked = HashSet<String>()
private val waiting = HashMap<String, MutableList<(Remote?) -> Unit>>()
private var online: Set<String> = emptySet()
private var onlineAt = 0L
private var badges: Map<String, List<RetroWfc.Badge>>? = null
/** Reads Retro Rewind's save; [done] gets null when there is none, or it is not readable. */
@@ -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<String>) -> Unit) {
if (onlineAt != 0L && SystemClock.elapsedRealtime() - onlineAt < ONLINE_REFRESH_MS) {
done(online)
return
}
worker.execute {
val fresh = try {
RetroWfc.onlineFriendCodes()
} catch (e: IOException) {
Log.i(TAG, "Retro WFC's rooms are unavailable: ${e.message}")
null
}
main.post {
if (fresh != null) {
online = fresh
onlineAt = SystemClock.elapsedRealtime()
}
done(online)
}
}
}
/** WheelWizard's badges by friend code, fetched once a session. */
fun badges(done: (Map<String, List<RetroWfc.Badge>>) -> Unit) {
badges?.let {
@@ -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<View> = listOf(root.findViewById(R.id.profiles_dot0), root.findViewById(R.id.profiles_dot1))
private val historyStart: TextView = root.findViewById(R.id.history_start)
private val historyEnd: TextView = root.findViewById(R.id.history_end)
private val historyChange: TextView = root.findViewById(R.id.history_change)
private val historyRange: TextView = root.findViewById(R.id.history_range)
private val historyLoading: View = root.findViewById(R.id.history_loading)
private val historyMatches: Switch = root.findViewById(R.id.history_matches)
private val historyGraph: View = root.findViewById(R.id.history_graph)
private val historyEmpty: View = root.findViewById(R.id.history_empty)
private val historyEmptyMessage: TextView = root.findViewById(R.id.history_empty_message)
private val historyMax: TextView = root.findViewById(R.id.history_max)
private val historyMin: TextView = root.findViewById(R.id.history_min)
private val historyChart: VrHistoryChart = root.findViewById(R.id.history_chart)
private val historyLabels: List<TextView> = listOf(
root.findViewById(R.id.history_label_start),
root.findViewById(R.id.history_label_mid),
root.findViewById(R.id.history_label_end),
)
private val 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<String> = emptySet()
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
private var carouselPage = 0
private var days = DEFAULT_DAYS
private var history: RetroWfc.History? = null
/** The friend code and period the history on screen, or on its way, is for. */
private var historyFor: Pair<String, Int>? = null
private var historyGeneration = 0
private val main = Handler(Looper.getMainLooper())
private var visible = false
private val onlineTicker = object : Runnable {
override fun run() {
refreshOnline()
main.postDelayed(this, ONLINE_REFRESH_MS)
}
}
private val roomsChanged: () -> Unit = { renderOnline() }
private val slotTabs: List<TextView> = (0 until RksysProfiles.SLOTS).map { index ->
TextView(activity).apply {
@@ -134,38 +94,19 @@ class ProfilesPage(
root.findViewById<View>(R.id.profiles_previous).setOnClickListener { moveCarousel(-1) }
root.findViewById<View>(R.id.profiles_next).setOnClickListener { moveCarousel(1) }
PatchesWidgets.styleSwitch(activity, historyMatches)
historyMatches.setOnCheckedChangeListener { _, _ -> renderHistory() }
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { historyMatches.toggle() }
root.findViewById<Spinner>(R.id.history_days).apply {
background = activity.getDrawable(R.drawable.bg_dropdown)
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
dropDownVerticalOffset = dp(4)
adapter = ArrayAdapter(activity, R.layout.item_dropdown, DAY_LABELS.map { activity.getString(it) }).apply {
setDropDownViewResource(R.layout.item_dropdown_popup)
}
setSelection(DAY_OPTIONS.indexOf(DEFAULT_DAYS), false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val chosen = DAY_OPTIONS[position]
if (chosen == days) return
days = chosen
current?.let(::loadHistory)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
renderCarousel()
}
/** While the page is on screen, who is online is asked again now and then, as the PC polls its rooms. */
/** 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
}
}
@@ -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<String>,
)
/** 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<RoomPlayer>,
)
/** 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<String> = parsing {
val rooms = JSONObject(json).opt("rooms") as? JSONArray ?: return@parsing emptySet()
(0 until rooms.length()).flatMap { index ->
val players = (rooms.opt(index) as? JSONObject)?.opt("players") as? JSONArray ?: return@flatMap emptyList()
(0 until players.length()).mapNotNull { (players.opt(it) as? JSONObject)?.text("friendCode") }
}.toSet()
/**
* 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<Room> = 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<LeaderboardEntry> = 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<JSONObject> =
(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<String, List<Badge>> = 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<String> = parseOnlineFriendCodes(get(ROOM_STATUS_URL) ?: "{}")
fun roomStatus(): List<Room> = parseRoomStatus(get(ROOM_STATUS_URL) ?: "{}")
fun topPlayers(limit: Int): List<LeaderboardEntry> = 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<String, List<Badge>> = parseBadges(get(BADGES_URL) ?: "{}")
@@ -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<String, List<RetroWfc.Badge>> = 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<View>(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<LiveRooms.Room> = 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<ImageView>(R.id.room_icon).setImageResource(if (room.isPublic) R.drawable.ic_road else R.drawable.ic_road_locked)
row.findViewById<TextView>(R.id.room_mode).text = room.gameMode
row.findViewById<TextView>(R.id.room_code).text = room.id
row.findViewById<TextView>(R.id.room_age).text = timeOnline(room)
row.findViewById<TextView>(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<LiveRooms.Player> = 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<ImageView>(R.id.player_mii)
val mii = player.mii
val drawn = mii != null && MiiRenderResource.installed(activity)
row.findViewById<View>(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<TextView>(R.id.player_name).text = player.name
row.findViewById<TextView>(R.id.player_code).text = player.friendCode
row.findViewById<TextView>(R.id.player_vr).text = player.vrText
val badgeList = badges[player.friendCode].orEmpty()
row.findViewById<View>(R.id.player_badges_box).visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE
row.findViewById<LinearLayout>(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<TextView>(R.id.player_top).apply {
text = player.topLabel
visibility = if (player.leaderboardRank != null) View.VISIBLE else View.GONE
}
row.findViewById<View>(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
}
}
@@ -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
}
@@ -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<TextView> = 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<String, Int>? = null
private var generation = 0
init {
PatchesWidgets.styleSwitch(activity, matches)
matches.setOnCheckedChangeListener { _, _ -> render() }
root.findViewById<View>(R.id.history_matches_row).setOnClickListener { matches.toggle() }
root.findViewById<Spinner>(R.id.history_days).apply {
background = activity.getDrawable(R.drawable.bg_dropdown)
setPopupBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
dropDownVerticalOffset = 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)
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- PlayerListItem's badge strip: a thin outline around the badges. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The round frame of an online player's Mii (PlayerListItem, PlayerProfileWindow). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/neutral_950" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's StateBox, its Danger variant: a player Retro WFC finds suspicious. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/danger_700" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's StateBox, its Dark variant: a room's player count. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_950" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's StateBox, its Success variant: a player's rank. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/primary_700" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- PlayerListItem's leaderboard place, such as #12. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/primary_600" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's InfoTip icon (Font Awesome, 512x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Lock icon (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's LockOpen icon (Font Awesome, 576x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="576"
android:viewportHeight="576">
<group android:translateY="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M352 144c0-44.2 35.8-80 80-80s80 35.8 80 80l0 48c0 17.7 14.3 32 32 32s32-14.3 32-32l0-48C576 64.5 511.5 0 432 0S288 64.5 288 144l0 48L64 192c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-192c0-35.3-28.7-64-64-64l-32 0 0-48z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Road icon (Font Awesome, 576x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="576"
android:viewportHeight="576">
<group android:translateY="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 32l-74.8 0c-27.1 0-51.3 17.1-60.3 42.6L3.1 407.2C1.1 413 0 419.2 0 425.4C0 455.5 24.5 480 54.6 480L256 480l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64 201.4 0c30.2 0 54.6-24.5 54.6-54.6c0-6.2-1.1-12.4-3.1-18.2L455.1 74.6C446 49.1 421.9 32 394.8 32L320 32l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64zm64 192l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's RoadLocked icon (Font Awesome, 640x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:translateY="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M288 32l-74.8 0c-27.1 0-51.3 17.1-60.3 42.6L35.1 407.2c-2.1 5.9-3.1 12-3.1 18.2C32 455.5 56.5 480 86.6 480L288 480l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64 32 0 0-128c0-23.7 12.9-44.4 32-55.4l0-24.6c0-58.3 44.6-106.2 101.5-111.5L487.1 74.6C478 49.1 453.9 32 426.8 32L352 32l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64zm64 192l0 64c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32zm176 16c17.7 0 32 14.3 32 32l0 48-64 0 0-48c0-17.7 14.3-32 32-32zm-80 32l0 48c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32l160 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's RoomUsers icon (Font Awesome, 640x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:translateY="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M96 0C43 0 0 43 0 96L0 416c0 53 43 96 96 96l448 0c53 0 96-43 96-96l0-320c0-53-43-96-96-96L96 0zM64 96c0-17.7 14.3-32 32-32l448 0c17.7 0 32 14.3 32 32l0 320c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32L64 96zm159.8 80a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM96 309.3c0 14.7 11.9 26.7 26.7 26.7l56.1 0c8-34.1 32.8-61.7 65.2-73.6c-7.5-4.1-16.2-6.4-25.3-6.4l-69.3 0C119.9 256 96 279.9 96 309.3zM461.2 336l56.1 0c14.7 0 26.7-11.9 26.7-26.7c0-29.5-23.9-53.3-53.3-53.3l-69.3 0c-9.2 0-17.8 2.3-25.3 6.4c32.4 11.9 57.2 39.5 65.2 73.6zM372 289c-3.9-.7-7.9-1-12-1l-80 0c-4.1 0-8.1 .3-12 1c-26 4.4-47.3 22.7-55.9 47c-2.7 7.5-4.1 15.6-4.1 24c0 13.3 10.7 24 24 24l176 0c13.3 0 24-10.7 24-24c0-8.4-1.4-16.5-4.1-24c-8.6-24.3-29.9-42.6-55.9-47zM512 176a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM320 256a64 64 0 1 0 0-128 64 64 0 1 0 0 128z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's UserCouple icon (Font Awesome, 640x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:translateY="64">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z" />
</group>
</vector>
@@ -180,6 +180,39 @@
android:text="@string/launcher_nav_settings" />
</LinearLayout>
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="14dp"
android:layout_marginBottom="6dp"
android:text="@string/launcher_section_online" />
<!-- A TapRow, so a tap on the count opens the page while the pointer still shows its tip. -->
<org.wiicompiled.quest.launcher.TapRow
android:id="@+id/nav_rooms"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_room_users" />
<TextView
style="@style/Launcher.NavLabel"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/launcher_nav_rooms" />
<!-- WheelWizard's BoxText: the players online now. -->
<TextView
android:id="@+id/nav_rooms_count"
style="@style/Launcher.StateBox"
android:drawableStart="@drawable/ic_user_couple"
android:text="0" />
</org.wiicompiled.quest.launcher.TapRow>
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
@@ -229,5 +262,10 @@
android:id="@+id/page_settings"
layout="@layout/page_settings"
android:visibility="gone" />
<include
android:id="@+id/page_rooms"
layout="@layout/page_rooms"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's MiiCarouselWindow (Views/Popups/MiiManagement/MiiCarouselWindow.axaml): the whole
Mii, turned by dragging it (Mii3DRender). -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="4dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="440dp"
android:layout_height="300dp">
<ProgressBar
android:id="@+id/mii_view_loading"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center"
android:indeterminateTint="@color/neutral_700" />
<TextView
android:id="@+id/mii_view_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:maxWidth="360dp"
android:textColor="@color/neutral_400"
android:textSize="14sp"
android:visibility="gone" />
<ImageView
android:id="@+id/mii_view_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/room_view_mii"
android:scaleType="fitCenter" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/mii_view_hint"
android:textColor="@color/neutral_500"
android:textSize="12sp" />
</LinearLayout>
@@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's PlayerProfileWindow (Views/Popups/PlayerProfileWindow.axaml): what Retro WFC
knows of a player, with their VR history. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="380dp"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp">
<LinearLayout
android:id="@+id/player_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<ProgressBar
android:layout_width="72dp"
android:layout_height="72dp"
android:indeterminateTint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/player_profile_loading"
android:textColor="@color/neutral_300"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/player_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="54dp"
android:layout_height="54dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_warning"
android:tint="@color/danger_600" />
<TextView
android:id="@+id/player_error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:maxWidth="440dp"
android:textColor="@color/danger_600"
android:textSize="14sp" />
</LinearLayout>
<ScrollView
android:id="@+id/player_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="84dp"
android:layout_height="84dp"
android:background="@drawable/bg_mii_circle"
android:clipToOutline="true">
<ImageView
android:id="@+id/player_profile_placeholder"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_600" />
<ImageView
android:id="@+id/player_profile_mii"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/player_profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:textColor="@color/neutral_300"
android:textSize="20sp" />
<TextView
android:id="@+id/player_profile_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/neutral_400"
android:textSize="12sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<TextView
android:id="@+id/player_profile_rank"
style="@style/Launcher.StateBox"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_state_box_success"
android:textColor="@color/primary_200" />
<TextView
android:id="@+id/player_profile_suspicious"
style="@style/Launcher.StateBox"
android:background="@drawable/bg_state_box_danger"
android:text="@string/player_profile_suspicious"
android:textColor="@color/danger_200" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="@color/neutral_700" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="2dp"
android:text="@string/room_vr"
android:tooltipText="@string/room_vr_full" />
<TextView
android:id="@+id/player_profile_vr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="14sp" />
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginTop="10dp"
android:layout_marginBottom="2dp"
android:text="@string/player_profile_vr_week" />
<TextView
android:id="@+id/player_profile_week"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="2dp"
android:text="@string/player_profile_last_seen" />
<TextView
android:id="@+id/player_profile_seen"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="12sp" />
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginTop="10dp"
android:layout_marginBottom="2dp"
android:text="@string/player_profile_vr_month" />
<TextView
android:id="@+id/player_profile_month"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="2dp"
android:text="@string/player_profile_vr_day" />
<TextView
android:id="@+id/player_profile_day"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="@color/neutral_700" />
<include
android:id="@+id/player_profile_history"
layout="@layout/view_vr_history"
android:layout_width="match_parent"
android:layout_height="270dp"
android:layout_marginTop="12dp" />
</LinearLayout>
</ScrollView>
</FrameLayout>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A player in a room or in the search (WheelWizard's PlayerListItem): their Mii, name and friend
code, VR, badges, leaderboard place and whether they host the room. -->
<org.wiicompiled.quest.launcher.TapRow xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_browser_item"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="7dp"
android:paddingEnd="7dp">
<FrameLayout
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="3dp"
android:layout_marginBottom="3dp"
android:background="@drawable/bg_mii_circle"
android:clipToOutline="true">
<ImageView
android:id="@+id/player_placeholder"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_600" />
<ImageView
android:id="@+id/player_mii"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no" />
</FrameLayout>
<LinearLayout
android:layout_width="170dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/player_name"
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:ellipsize="end"
android:singleLine="true" />
<TextView
android:id="@+id/player_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="90dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/room_vr"
android:tooltipText="@string/room_vr_full" />
<TextView
android:id="@+id/player_vr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1">
<HorizontalScrollView
android:id="@+id/player_badges_box"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_badge_box"
android:padding="4dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_badges"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal" />
</HorizontalScrollView>
</FrameLayout>
<TextView
android:id="@+id/player_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:background="@drawable/bg_top_label"
android:fontFamily="sans-serif-medium"
android:paddingStart="10dp"
android:paddingTop="6dp"
android:paddingEnd="10dp"
android:paddingBottom="6dp"
android:textColor="@android:color/white"
android:textSize="13sp" />
<ImageView
android:id="@+id/player_host"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/room_open_host"
android:src="@drawable/ic_lock_open"
android:tint="@color/primary_400"
android:tooltipText="@string/room_open_host" />
</org.wiicompiled.quest.launcher.TapRow>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A room on the Rooms page (RoomsPage's item template): open or locked, its game mode and ID,
how long it has been open, and its player count. -->
<org.wiicompiled.quest.launcher.TapRow xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_browser_item"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="7dp"
android:paddingEnd="7dp">
<ImageView
android:id="@+id/room_icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="3dp"
android:layout_marginBottom="3dp"
android:padding="4dp"
android:importantForAccessibility="no"
android:tint="@color/neutral_500" />
<LinearLayout
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/room_mode"
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:ellipsize="end"
android:singleLine="true" />
<TextView
android:id="@+id/room_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/rooms_time_online" />
<TextView
android:id="@+id/room_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/room_count"
style="@style/Launcher.StateBox"
android:layout_marginEnd="10dp"
android:background="@drawable/bg_state_box_dark"
android:drawableStart="@drawable/ic_user_couple"
android:textColor="@color/neutral_200" />
</org.wiicompiled.quest.launcher.TapRow>
@@ -220,249 +220,11 @@
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
<include
android:id="@+id/profiles_history"
layout="@layout/view_vr_history"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_start" />
<TextView
android:id="@+id/history_start"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_end" />
<TextView
android:id="@+id/history_end"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_change" />
<TextView
android:id="@+id/history_change"
style="@style/Launcher.StatValue" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/history_title" />
<ProgressBar
android:id="@+id/history_loading"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="8dp"
android:indeterminateTint="@color/neutral_500"
android:visibility="gone" />
<LinearLayout
android:id="@+id/history_matches_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal">
<Switch
android:id="@+id/history_matches"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/history_matches"
android:textColor="@color/neutral_200"
android:textSize="12sp" />
</LinearLayout>
<Spinner
android:id="@+id/history_days"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp" />
</LinearLayout>
<TextView
android:id="@+id/history_range"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/history_graph"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_row_single"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/history_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_min"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</FrameLayout>
<Space
android:layout_width="0dp"
android:layout_height="22dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<org.wiicompiled.quest.launcher.VrHistoryChart
android:id="@+id/history_chart"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/history_label_start"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_mid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_end"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/history_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_chart"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="@string/history_none_title"
android:textColor="@color/neutral_200"
android:textSize="16sp" />
<TextView
android:id="@+id/history_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:maxWidth="320dp"
android:textColor="@color/neutral_400"
android:textSize="13sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/profiles_stats"
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's RoomDetailsPage (Views/Pages/RoomDetailsPage.axaml): a room's ID, time online,
game mode and average VR, and its players; a player opens their actions. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="88dp">
<TextView
android:id="@+id/room_back"
style="@style/Launcher.LinkButton"
android:layout_height="36dp"
android:layout_gravity="top|start"
android:layout_marginStart="-6dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_nav_item"
android:clickable="true"
android:drawableStart="@drawable/ic_arrow_left"
android:drawablePadding="6dp"
android:drawableTint="@color/neutral_300"
android:focusable="true"
android:text="@string/launcher_nav_rooms"
android:textColor="@color/neutral_300"
android:textSize="16sp" />
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginBottom="10dp"
android:text="@string/room_details_title" />
<ImageView
android:id="@+id/room_lock"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="3dp"
android:layout_marginBottom="13dp"
android:contentDescription="@string/rooms_private"
android:src="@drawable/ic_lock"
android:tint="@color/neutral_300"
android:tooltipText="@string/rooms_private"
android:visibility="gone" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="12dp"
android:paddingBottom="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="2dp"
android:text="@string/room_id" />
<TextView
android:id="@+id/room_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textIsSelectable="true"
android:textSize="15sp" />
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginTop="12dp"
android:layout_marginBottom="2dp"
android:text="@string/room_type" />
<TextView
android:id="@+id/room_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="15sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="2dp"
android:text="@string/rooms_time_online" />
<TextView
android:id="@+id/room_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_100"
android:textSize="15sp" />
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
android:text="@string/room_average_vr" />
<TextView
android:id="@+id/room_average_vr"
style="@style/Launcher.StateBox" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="38dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/rooms_list_players" />
<TextView
android:id="@+id/room_player_count"
style="@style/Launcher.StateBox"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Above the players' VR column (item_player.xml). -->
<TextView
style="@style/Launcher.FieldLabel"
android:layout_gravity="center_vertical"
android:layout_marginStart="248dp"
android:layout_marginBottom="0dp"
android:text="@string/room_vr"
android:tooltipText="@string/room_vr_full" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ListView
android:id="@+id/room_players"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:divider="@android:color/transparent"
android:dividerHeight="6dp"
android:listSelector="@android:color/transparent"
android:paddingTop="8dp"
android:paddingBottom="12dp" />
</LinearLayout>
@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Rooms page (Views/Pages/RoomsPage.axaml): the rooms open on Retro WFC, or the
players whose name or friend code holds the search, with a room's details
(page_room_details.xml) opened in place of the list. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/rooms_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="bottom"
android:orientation="horizontal"
android:paddingBottom="10dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/launcher_nav_rooms" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:layout_marginBottom="9dp"
android:contentDescription="@string/rooms_disclaimer"
android:src="@drawable/ic_info"
android:tint="@color/neutral_500"
android:tooltipText="@string/rooms_disclaimer" />
</LinearLayout>
<EditText
android:id="@+id/rooms_search"
android:layout_width="240dp"
android:layout_height="40dp"
android:layout_gravity="bottom|end"
android:layout_marginBottom="10dp"
android:background="@drawable/bg_search_field"
android:hint="@string/rooms_search_hint"
android:imeOptions="actionSearch"
android:importantForAutofill="no"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:singleLine="true"
android:textColor="@color/neutral_100"
android:textColorHint="@color/neutral_500"
android:textSize="15sp" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/rooms_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_road"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="@string/rooms_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxWidth="440dp"
android:text="@string/rooms_empty_message"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rooms_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="6dp">
<TextView
android:id="@+id/rooms_list_title"
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp" />
<TextView
android:id="@+id/rooms_list_count"
style="@style/Launcher.StateBox"
android:layout_marginStart="8dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ListView
android:id="@+id/rooms_items"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:divider="@android:color/transparent"
android:dividerHeight="6dp"
android:listSelector="@android:color/transparent"
android:paddingTop="8dp"
android:paddingBottom="12dp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
<include
android:id="@+id/rooms_details"
layout="@layout/page_room_details"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,245 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's VrHistoryGraph (VrHistoryPanel): a player's VR over a chosen period, by time or
by match, on My profiles and in the Rooms page's player profiles. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_start" />
<TextView
android:id="@+id/history_start"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_end" />
<TextView
android:id="@+id/history_end"
style="@style/Launcher.StatValue" />
</LinearLayout>
<LinearLayout
style="@style/Launcher.StatCard"
android:layout_marginStart="4dp">
<TextView
style="@style/Launcher.StatLabel"
android:text="@string/history_change" />
<TextView
android:id="@+id/history_change"
style="@style/Launcher.StatValue" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/history_title" />
<ProgressBar
android:id="@+id/history_loading"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="8dp"
android:indeterminateTint="@color/neutral_500"
android:visibility="gone" />
<LinearLayout
android:id="@+id/history_matches_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal">
<Switch
android:id="@+id/history_matches"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/history_matches"
android:textColor="@color/neutral_200"
android:textSize="12sp" />
</LinearLayout>
<Spinner
android:id="@+id/history_days"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp" />
</LinearLayout>
<TextView
android:id="@+id/history_range"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/history_graph"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_row_single"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/history_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_min"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</FrameLayout>
<Space
android:layout_width="0dp"
android:layout_height="22dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<org.wiicompiled.quest.launcher.VrHistoryChart
android:id="@+id/history_chart"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/history_label_start"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_mid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
<TextView
android:id="@+id/history_label_end"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textColor="@color/neutral_400"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/history_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_chart"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="@string/history_none_title"
android:textColor="@color/neutral_200"
android:textSize="16sp" />
<TextView
android:id="@+id/history_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:maxWidth="320dp"
android:textColor="@color/neutral_400"
android:textSize="13sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -31,9 +31,12 @@
<color name="warning_600">#D19200</color>
<color name="warning_700">#A66802</color>
<color name="warning_800">#89510A</color>
<color name="danger_200">#FFC5CF</color>
<color name="danger_300">#FF9DAF</color>
<color name="danger_400">#FF6581</color>
<color name="danger_500">#FE2048</color>
<color name="danger_600">#EC163D</color>
<color name="danger_700">#C70E2F</color>
<!-- The mod browser's look for mods that use patches (Views/Patterns/ModBrowserListItem.axaml). -->
<color name="patches_item">#173726</color>
@@ -11,6 +11,13 @@
<string name="launcher_nav_patches">Patches</string>
<string name="launcher_nav_miis">My Miis</string>
<string name="launcher_nav_settings">Settings</string>
<string name="launcher_section_online">Online</string>
<string name="launcher_nav_rooms">Rooms</string>
<string name="launcher_players_online_none">There are currently no players online</string>
<plurals name="launcher_players_online">
<item quantity="one">There is currently %d player online</item>
<item quantity="other">There are currently %d players online</item>
</plurals>
<string name="launcher_version">v%1$s</string>
<!-- Home -->
@@ -158,6 +165,59 @@
<string name="history_end">End VR</string>
<string name="history_change">Net Change</string>
<string name="history_title">VR History</string>
<!-- Rooms (WheelWizard's RoomsPage, RoomDetailsPage and PlayerProfileWindow) -->
<string name="rooms_disclaimer">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.</string>
<string name="rooms_search_hint">Search For Players...</string>
<string name="rooms_empty_title">No rooms found</string>
<string name="rooms_empty_message">You might not have internet connection, or no-one might be playing.</string>
<string name="rooms_list_rooms">Rooms</string>
<string name="rooms_list_players">Players</string>
<string name="rooms_time_online">Time Online</string>
<string name="rooms_private">Private room</string>
<string name="room_details_title">Room Details</string>
<string name="room_id">ID</string>
<string name="room_type">Type</string>
<string name="room_average_vr">Average VR</string>
<string name="room_vr">VR</string>
<string name="room_vr_full">Versus Rating</string>
<string name="room_open_host">Open Host</string>
<string name="room_copy_friend_code">Copy Friend Code</string>
<string name="room_view_mii">View Mii</string>
<string name="room_view_profile">View Profile</string>
<string name="room_no_mii">This player has no valid Mii data.</string>
<plurals name="time_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<plurals name="time_hours">
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<plurals name="time_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<plurals name="time_seconds">
<item quantity="one">%d second</item>
<item quantity="other">%d seconds</item>
</plurals>
<string name="player_profile_title">Player Profile</string>
<string name="player_profile_loading">Loading profile...</string>
<string name="player_profile_unknown">Retro WFC does not know this player.</string>
<string name="player_profile_rank">Rank #%1$d</string>
<string name="player_profile_suspicious">Suspicious</string>
<string name="player_profile_last_seen">Last Seen</string>
<string name="player_profile_vr_day">VR (24h)</string>
<string name="player_profile_vr_week">VR (Week)</string>
<string name="player_profile_vr_month">VR (Month)</string>
<string name="player_profile_days_ago">%1$d day(s) ago</string>
<string name="player_profile_hours_ago">%1$d hour(s) ago</string>
<string name="player_profile_minutes_ago">%1$d minute(s) ago</string>
<string name="player_profile_just_now">Just now</string>
<string name="mii_view_hint">Drag to turn the Mii; the thumbstick zooms.</string>
<string name="mii_view_no_parts">Download the Mii parts on My Miis to see this Mii here.</string>
<string name="dialog_close">Close</string>
<string name="history_matches">Matches</string>
<string name="history_days_1">Last 24 hours</string>
<string name="history_days_7">Last 7 days</string>
@@ -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<String> = 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.Player>(), 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)
}
}
@@ -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 }
@@ -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<String>(), 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.Room>(), 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