Added Quest friend management features

- Added `ic_question_tip.xml` and `ic_success_tip.xml` vector drawables for UI icons.
- Created layout files for friend-related dialogs: `dialog_add_friend.xml` and `dialog_friend_code.xml`.
- Implemented `item_friend.xml` for displaying individual friend cards in the friends list.
- Developed `page_friends.xml` for the main friends page layout, including empty state handling.
- Added unit tests in `RksysFriendsTest.kt` to validate friend management logic and interactions.
- Introduced `SidebarStatusTest.kt` to ensure proper status reading and SVG path parsing.
This commit is contained in:
iChris4 committed 2026-09-24 17:04:18 +02:00
1 parent 775d9f89d8
commit 13311ea626
39 files changed
+2670 -50

No files matched your search

@@ -0,0 +1,246 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.app.AlertDialog
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import java.io.IOException
import java.util.concurrent.Executors
import org.wiicompiled.quest.R
/**
* Adding and removing friends as WheelWizard does, into the friend list of the licence the sidebar
* shows ([FriendList]): by friend code from the Friends page (FriendsPage.AddFriend_OnClick, which
* looks the code up on Retro WFC for the player's name and Mii), or a player met in a room or on
* the leaderboard (their pages' AddFriend_OnClick), each confirmed first as the PC's
* AddFriendConfirmationWindow does. The game reads the save while it runs, so nothing is changed
* then. Unlike the PC, removing a friend asks first, since a tap is easy to misplace in a headset.
*/
class FriendActions(private val activity: Activity, private val gameRunning: () -> Boolean) {
/** The Friends page's Add Friend: a code typed in, looked up, confirmed and saved. */
fun addByCode() {
if (!canAdd()) return
val view = activity.layoutInflater.inflate(R.layout.dialog_friend_code, null)
val input: EditText = view.findViewById(R.id.friend_code_input)
val note: TextView = view.findViewById(R.id.friend_code_note)
val progress: ProgressBar = view.findViewById(R.id.friend_code_progress)
val dialog = AlertDialog.Builder(activity)
.setTitle(R.string.friends_add)
.setView(view)
.setPositiveButton(R.string.friends_submit, null)
.setNegativeButton(android.R.string.cancel, null)
.create()
var looking = false
/** The PC's validation (an error, which blocks) and warning (which only tells). */
fun check(): String? {
val result = RksysFriends.normalize(input.text.toString())
val code = result.friendCode
val problem = result.problem ?: if (code != null && isOwn(code)) RksysFriends.Problem.Own else null
val submit = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
submit?.isEnabled = problem == null && !looking
progress.visibility = View.GONE
when {
// Nothing typed yet is not worth a message.
problem == RksysFriends.Problem.Empty -> note.text = ""
problem != null -> show(note, message(problem), R.color.danger_400)
code != null && isFriend(code) -> show(note, activity.getString(R.string.friends_already), R.color.warning_400)
else -> note.text = ""
}
return if (problem == null) code else null
}
fun submit() {
val code = check() ?: return
looking = true
input.isEnabled = false
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false
progress.visibility = View.VISIBLE
show(note, activity.getString(R.string.friends_looking_up), R.color.neutral_400)
worker.execute {
val profile = try {
RetroWfc.playerProfile(code)
} catch (e: IOException) {
null
}
main.post {
if (!dialog.isShowing || activity.isDestroyed) return@post
looking = false
input.isEnabled = true
val mii = profile?.mii?.let { data -> runCatching { MiiData.parse(data) }.getOrNull() }
when {
profile == null -> fail(note, progress, dialog, R.string.friends_not_found)
mii == null -> fail(note, progress, dialog, R.string.friends_profile_no_mii)
else -> {
dialog.dismiss()
confirm(profile.name, profile.friendCode.ifBlank { code }, mii, profile.vr)
}
}
}
}
}
input.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?) {
check()
}
})
input.setOnEditorActionListener { _, action, _ ->
if (action == EditorInfo.IME_ACTION_DONE && !looking) submit()
action == EditorInfo.IME_ACTION_DONE
}
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { submit() }
check()
input.requestFocus()
}
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
dialog.show()
}
/** A player from a room or the leaderboard, with the Mii and VR shown there. */
fun addPlayer(name: String, friendCode: String, mii: Mii?, vr: Int?) {
if (mii == null) {
toast(activity.getString(R.string.room_no_mii))
return
}
if (!canAdd()) return
val result = RksysFriends.normalize(friendCode)
val code = result.friendCode
val problem = result.problem ?: when {
code != null && isOwn(code) -> RksysFriends.Problem.Own
code != null && isFriend(code) -> RksysFriends.Problem.Duplicate
else -> null
}
if (problem != null || code == null) {
toast(message(problem ?: RksysFriends.Problem.Invalid))
return
}
confirm(name, code, mii, vr ?: 0)
}
/** RemoveFriend_OnClick, once confirmed. */
fun remove(friend: RksysFriends.Friend) {
if (refuseWhileGameRuns()) return
AlertDialog.Builder(activity)
.setTitle(R.string.friends_remove)
.setMessage(activity.getString(R.string.friends_remove_question, friend.name, friend.friendCode))
.setPositiveButton(R.string.friends_remove) { _, _ ->
if (refuseWhileGameRuns()) return@setPositiveButton
FriendList.remove(activity, friend) { problem ->
toast(if (problem == null) activity.getString(R.string.friends_removed, friend.name) else message(problem))
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/** AddFriendConfirmationWindow, then AddFriend. */
private fun confirm(name: String, friendCode: String, mii: Mii, vr: Int) {
val shownName = name.replace("\u0000", "").ifBlank { activity.getString(R.string.friends_unknown_player) }
val view = activity.layoutInflater.inflate(R.layout.dialog_add_friend, null)
view.findViewById<TextView>(R.id.add_friend_name).text = shownName
view.findViewById<TextView>(R.id.add_friend_code).text = friendCode
view.findViewById<TextView>(R.id.add_friend_vr).text = activity.getString(R.string.friends_confirm_vr, vr)
val picture: ImageView = view.findViewById(R.id.add_friend_mii)
if (MiiRenderResource.installed(activity)) {
// FriendsSideProfile, drawn at twice its pixels for smooth edges.
val size = (2 * CONFIRM_MII_DP * activity.resources.displayMetrics.density).toInt() and 1.inv()
MiiImages.picture(activity, mii, size, MiiRenderer.Pose.SIDE) { bitmap ->
if (bitmap == null) return@picture
picture.setImageBitmap(bitmap)
view.findViewById<View>(R.id.add_friend_placeholder).visibility = View.GONE
}
}
AlertDialog.Builder(activity)
.setTitle(R.string.friends_add)
.setView(view)
.setPositiveButton(R.string.friends_add) { _, _ ->
if (refuseWhileGameRuns()) return@setPositiveButton
FriendList.add(activity, friendCode, mii, vr) { problem ->
toast(if (problem == null) activity.getString(R.string.friends_added, shownName) else message(problem))
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/** The PC's checks before asking for a friend: a licence with a friend code, and room for one more. */
private fun canAdd(): Boolean {
if (refuseWhileGameRuns()) return false
val license = FriendList.license
if (license == null || license.profileId == 0L) {
toast(message(RksysFriends.Problem.NoLicense))
return false
}
if (FriendList.friends.size >= RksysFriends.MAX) {
toast(activity.getString(R.string.friends_list_full))
return false
}
return true
}
private fun isOwn(friendCode: String): Boolean {
val own = FriendList.license?.profileId ?: 0L
return own != 0L && own == RksysFriends.profileId(friendCode)
}
private fun isFriend(friendCode: String): Boolean {
val profileId = RksysFriends.profileId(friendCode)
return profileId != 0L && FriendList.friends.any { it.profileId == profileId }
}
private fun refuseWhileGameRuns(): Boolean {
if (!gameRunning()) return false
Toast.makeText(activity, R.string.friends_close_game, Toast.LENGTH_LONG).show()
return true
}
private fun fail(note: TextView, progress: View, dialog: AlertDialog, text: Int) {
progress.visibility = View.GONE
show(note, activity.getString(text), R.color.danger_400)
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true
}
private fun show(note: TextView, text: String, color: Int) {
note.text = text
note.setTextColor(activity.getColor(color))
}
private fun toast(text: String) = Toast.makeText(activity, text, Toast.LENGTH_LONG).show()
private fun message(problem: RksysFriends.Problem): String = activity.getString(
when (problem) {
RksysFriends.Problem.Empty -> R.string.friends_code_empty
RksysFriends.Problem.NotTwelveDigits -> R.string.friends_code_digits
RksysFriends.Problem.Invalid -> R.string.friends_code_invalid
RksysFriends.Problem.Own -> R.string.friends_code_own
RksysFriends.Problem.Duplicate -> R.string.friends_already
RksysFriends.Problem.Full -> R.string.friends_list_full_remove
RksysFriends.Problem.NoLicense -> R.string.friends_no_license
RksysFriends.Problem.NotFound -> R.string.friends_not_in_list
RksysFriends.Problem.NotASave -> R.string.friends_no_save
RksysFriends.Problem.CannotWrite -> R.string.friends_cannot_write
},
)
private companion object {
/** The Mii's circle in dialog_add_friend.xml. */
const val CONFIRM_MII_DP = 96
val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "AddFriend").apply { isDaemon = true } }
val main by lazy { Handler(Looper.getMainLooper()) }
}
}
@@ -0,0 +1,131 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.Executors
import org.wiicompiled.quest.GameStorage
/**
* The friends of the licence the sidebar shows (GameLicenseService.ActiveCurrentFriends on the
* PC, which follows its focused licence) in Retro Rewind's save, for the Friends page and the
* sidebar's count, and the changes to them ([RksysFriends]). The save is read again whenever the
* launcher comes back, since the game may have changed it, and after every change here. A change
* reads the whole save, changes it in memory and replaces the file with a finished copy, so a
* failure part way never leaves half a save. Callers keep changes away from a running game.
*/
object FriendList {
private const val TAG = "WiiCompiledLauncher"
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "Friends").apply { isDaemon = true } }
private val main by lazy { Handler(Looper.getMainLooper()) }
private val listeners = LinkedHashSet<() -> Unit>()
/** The licence whose friends these are, null without a save or a licence; main thread only. */
var license: RksysProfiles.License? = null
private set
val friends: List<RksysFriends.Friend> get() = license?.friends.orEmpty()
/** Friends in a room now, as the PC's IsOnline finds them among the live rooms' players. */
fun isOnline(friend: RksysFriends.Friend): Boolean = Leaderboard.roomOf(LiveRooms.rooms, friend.friendCode) != null
val onlineCount: Int get() = friends.count(::isOnline)
/** The Friends page's Sort by (ListOrderCondition), in the PC's order. */
enum class Order { Online, Vr, Br, Name, Wins, Races }
/**
* GetSortedPlayerList: highest first, and online before offline, keeping the save's order
* among equals. Unlike the PC, which sorts names from Z to A too, names go from A to Z.
*/
fun sorted(friends: List<RksysFriends.Friend>, order: Order, online: (RksysFriends.Friend) -> Boolean): List<RksysFriends.Friend> =
when (order) {
Order.Online -> friends.sortedByDescending(online)
Order.Vr -> friends.sortedByDescending { it.vr }
Order.Br -> friends.sortedByDescending { it.br }
Order.Name -> friends.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
Order.Wins -> friends.sortedByDescending { it.wins }
Order.Races -> friends.sortedByDescending { it.wins + it.losses }
}
/** [listener] is called on the main thread whenever the list is read again. */
fun addListener(listener: () -> Unit) {
listeners += listener
}
fun removeListener(listener: () -> Unit) {
listeners -= listener
}
/** Reads the save again. */
fun load(context: Context) = ProfileStore.load(context) { show(context, it) }
/** Takes the friends of the licence [snapshot] gives the sidebar, as the save was just read. */
fun show(context: Context, snapshot: ProfileStore.Snapshot?) {
license = snapshot?.let { ProfileStore.sidebarLicense(context, it) }
listeners.toList().forEach { it() }
}
/**
* AddFriend: [friendCode] (as [RksysFriends.normalize] gives it) with [mii] and [vr] joins the
* licence's list as a request not yet answered. [done] gets null once it is saved and the list
* read again, else why not.
*/
fun add(context: Context, friendCode: String, mii: Mii, vr: Int, done: (RksysFriends.Problem?) -> Unit) {
val slot = license?.slot ?: return done(RksysFriends.Problem.NoLicense)
val data = MiiData.serialize(mii)
change(context, done) { save -> RksysFriends.add(save, slot, friendCode, data, vr) }
}
/** RemoveFriend: [friend] leaves the licence's list; [done] as for [add]. */
fun remove(context: Context, friend: RksysFriends.Friend, done: (RksysFriends.Problem?) -> Unit) {
val slot = license?.slot ?: return done(RksysFriends.Problem.NoLicense)
change(context, done) { save -> if (RksysFriends.remove(save, slot, friend.profileId)) null else RksysFriends.Problem.NotFound }
}
private fun change(context: Context, done: (RksysFriends.Problem?) -> Unit, edit: (ByteArray) -> RksysFriends.Problem?) {
val app = context.applicationContext
worker.execute {
val problem = write(GameStorage.retroRewindSave(app), edit)
main.post {
if (problem != null) {
done(problem)
return@post
}
ProfileStore.load(app) { snapshot ->
show(app, snapshot)
done(null)
}
}
}
}
private fun write(file: File, edit: (ByteArray) -> RksysFriends.Problem?): RksysFriends.Problem? {
val save = try {
file.readBytes()
} catch (e: IOException) {
Log.w(TAG, "Cannot read Retro Rewind's save", e)
return RksysFriends.Problem.NotASave
}
if (!RksysFriends.isSave(save)) return RksysFriends.Problem.NotASave
edit(save)?.let { return it }
val temporary = File(file.parentFile, "${file.name}.tmp")
return try {
FileOutputStream(temporary).use { output ->
output.write(save)
output.fd.sync()
}
if (!temporary.renameTo(file)) throw IOException("Cannot replace $file")
null
} catch (e: IOException) {
Log.w(TAG, "Cannot write Retro Rewind's save", e)
temporary.delete()
RksysFriends.Problem.CannotWrite
}
}
}
@@ -0,0 +1,254 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import org.wiicompiled.quest.R
/**
* The launcher's Friends page, WheelWizard's FriendsPage: the friend list of the licence the
* sidebar shows ([FriendList]), sorted as chosen, each friend a FriendsListItem card whose View
* Room opens their room on the Rooms page ([viewRoom]) while they play. A tap on a card opens the
* player's actions, Remove Friend among them; Add Friend asks for a friend code ([FriendActions]).
* The save is read again each time the page is shown, and who is online follows [LiveRooms].
*/
class FriendsPage(
private val activity: Activity,
root: View,
private val actions: FriendActions,
private val viewRoom: (LiveRooms.Room) -> Unit,
) {
private val content: View = root.findViewById(R.id.friends_content)
private val empty: View = root.findViewById(R.id.friends_empty)
private val addTop: View = root.findViewById(R.id.friends_add_top)
private val count: TextView = root.findViewById(R.id.friends_count)
private val list: ListView = root.findViewById(R.id.friends_list)
private val adapter = CardAdapter()
private var visible = false
private var badges: Map<String, List<RetroWfc.Badge>> = emptyMap()
private val changed: () -> Unit = { render() }
private val density = activity.resources.displayMetrics.density
/** The Mii picture in item_friend.xml, drawn at twice its pixels for smooth edges. */
private val miiSize = (2 * MII_DP * density).toInt() and 1.inv()
init {
list.adapter = adapter
addTop.setOnClickListener { actions.addByCode() }
root.findViewById<View>(R.id.friends_add_empty).setOnClickListener { actions.addByCode() }
root.findViewById<Spinner>(R.id.friends_sort).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, FriendList.Order.entries.map { activity.getString(label(it)) }).apply {
setDropDownViewResource(R.layout.item_dropdown_popup)
}
setSelection(order.ordinal, false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (order.ordinal == position) return
order = FriendList.Order.entries[position]
render()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
}
/** While the page is on screen it follows the save and who is online. */
fun setVisible(shown: Boolean) {
if (shown == visible) return
visible = shown
if (!shown) {
FriendList.removeListener(changed)
LiveRooms.removeListener(changed)
return
}
FriendList.addListener(changed)
LiveRooms.addListener(changed)
ProfileStore.badges { loaded ->
badges = loaded
if (!activity.isDestroyed) render()
}
render()
// The game may have changed the list since it was read.
FriendList.load(activity)
}
/** UpdateFriendList and HandleVisibility. */
private fun render() {
if (activity.isDestroyed) return
val friends = FriendList.sorted(FriendList.friends, order, FriendList::isOnline)
val any = friends.isNotEmpty()
content.visibility = if (any) View.VISIBLE else View.GONE
empty.visibility = if (any) View.GONE else View.VISIBLE
addTop.visibility = if (any) View.VISIBLE else View.GONE
count.text = friends.size.toString()
adapter.friends = friends
adapter.notifyDataSetChanged()
}
private fun label(order: FriendList.Order): Int = when (order) {
FriendList.Order.Online -> R.string.friends_sort_online
FriendList.Order.Vr -> R.string.room_vr_full
FriendList.Order.Br -> R.string.friends_br_full
FriendList.Order.Name -> R.string.friends_sort_name
FriendList.Order.Wins -> R.string.friends_wins
FriendList.Order.Races -> R.string.friends_sort_races
}
/** ViewRoom_OnClick. */
private fun openRoomOf(friend: RksysFriends.Friend) {
val room = Leaderboard.roomOf(LiveRooms.rooms, friend.friendCode)
if (room == null) {
Toast.makeText(activity, R.string.friends_no_room, Toast.LENGTH_LONG).show()
return
}
viewRoom(room)
}
/** FriendsListItem cards. */
private inner class CardAdapter : BaseAdapter() {
var friends: List<RksysFriends.Friend> = emptyList()
override fun getCount(): Int = friends.size
override fun getItem(position: Int): Any = friends[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val card = convertView ?: LayoutInflater.from(activity).inflate(R.layout.item_friend, parent, false).also(::sizeIcons)
val friend = friends[position]
val online = FriendList.isOnline(friend)
val pending = friend.isPending
// Pending shows over online, as the PC's styles order them.
val accent = when {
pending -> R.color.warning_500
online -> R.color.primary_400
else -> null
}
card.foreground = accent?.let { outline(activity.getColor(it), 6f, 1) }
card.findViewById<View>(R.id.friend_glow).apply {
visibility = if (pending || online) View.VISIBLE else View.GONE
if (pending || online) background = glow(activity.getColor(if (pending) R.color.warning_200 else R.color.primary_200))
}
card.findViewById<TextView>(R.id.friend_status).apply {
setText(if (pending) R.string.friends_pending else if (online) R.string.friends_online else R.string.friends_offline)
setTextColor(activity.getColor(if (pending) R.color.warning_400 else R.color.neutral_200))
}
card.findViewById<TextView>(R.id.friend_name).text = friend.name
card.findViewById<TextView>(R.id.friend_code).text = friend.friendCode
card.findViewById<TextView>(R.id.friend_vr).text = rating(friend.vr)
card.findViewById<TextView>(R.id.friend_br).text = rating(friend.br)
card.findViewById<TextView>(R.id.friend_wins).text = friend.wins.toString()
card.findViewById<TextView>(R.id.friend_losses).text = friend.losses.toString()
val badgeList = badges[friend.friendCode].orEmpty()
card.findViewById<View>(R.id.friend_badges_box).apply {
visibility = if (badgeList.isEmpty()) View.GONE else View.VISIBLE
background = outline(activity.getColor(accent ?: R.color.neutral_600), 6f, 1, fill = activity.getColor(R.color.neutral_950))
}
card.findViewById<LinearLayout>(R.id.friend_badges).apply {
removeAllViews()
badgeList.forEachIndexed { index, badge ->
val size = PatchesWidgets.dp(activity, 30)
addView(BadgeView(activity, badge), LinearLayout.LayoutParams(size, size).apply {
if (index > 0) marginStart = PatchesWidgets.dp(activity, 3)
})
}
}
card.findViewById<TextView>(R.id.friend_view_room).apply {
isEnabled = online
// The Warning variant for a pending friend; only its icon beside badges.
setBackgroundResource(if (pending) R.drawable.bg_button_warning else R.drawable.bg_play_button)
val content = activity.getColorStateList(if (pending) R.color.warning_button_content else R.color.play_button_content)
setTextColor(content)
compoundDrawableTintList = content
text = if (badgeList.isEmpty()) activity.getString(R.string.leaderboard_view_room) else ""
compoundDrawablePadding = if (badgeList.isEmpty()) PatchesWidgets.dp(activity, 10) else 0
val side = PatchesWidgets.dp(activity, if (badgeList.isEmpty()) 16 else 12)
setPaddingRelative(side, 0, side, 0)
tooltipText = activity.getString(R.string.leaderboard_view_room)
setOnClickListener { openRoomOf(friend) }
}
// FriendsSideProfile, or FriendsSideProfilePending's angry face.
val picture = card.findViewById<ImageView>(R.id.friend_mii)
val placeholder = card.findViewById<View>(R.id.friend_placeholder)
val expression = if (pending) EXPRESSION_ANGER else 0
val key = "${friend.profileId}:$expression:${friend.mii.lookKey()}"
if (picture.tag != key) {
picture.tag = key
picture.setImageDrawable(null)
placeholder.visibility = View.VISIBLE
if (MiiRenderResource.installed(activity)) {
MiiImages.picture(activity, friend.mii, miiSize, MiiRenderer.Pose.SIDE, expression) { bitmap ->
if (picture.tag != key || bitmap == null) return@picture
picture.setImageBitmap(bitmap)
placeholder.visibility = View.GONE
}
}
}
card.setOnClickListener {
PlayerActions.show(activity, card, friend.friendCode, friend.mii, removeFriend = { actions.remove(friend) })
}
return card
}
}
/** The PC shows the most a friend's rating can hold, 9999, as 9999+. */
private fun rating(value: Int): String = if (value == 9999) "9999+" else value.toString()
/** The labels' tips and View Room's road at the PC's sizes. */
private fun sizeIcons(card: View) {
listOf(R.id.friend_vr_label to 12, R.id.friend_br_label to 12, R.id.friend_view_room to 16).forEach { (id, dp) ->
card.findViewById<TextView>(id).apply {
val size = PatchesWidgets.dp(activity, dp)
val drawables = compoundDrawablesRelative.map { drawable -> drawable?.mutate()?.apply { setBounds(0, 0, size, size) } }
setCompoundDrawablesRelative(drawables[0], drawables[1], drawables[2], drawables[3])
}
}
}
private fun outline(color: Int, radiusDp: Float, widthDp: Int, fill: Int? = null) = GradientDrawable().apply {
cornerRadius = radiusDp * density
setStroke((widthDp * density).toInt().coerceAtLeast(1), color)
fill?.let(::setColor)
}
/**
* The PC's glow: a 100 x 175 blob at half opacity, blurred by 250 pixels, which leaves a soft
* wash of colour at the card's end; a radial fade gives the same.
*/
private fun glow(color: Int) = GradientDrawable().apply {
shape = GradientDrawable.OVAL
gradientType = GradientDrawable.RADIAL_GRADIENT
gradientRadius = 150 * density
colors = intArrayOf(withAlpha(color, 0.3f), withAlpha(color, 0.12f), withAlpha(color, 0f))
}
private fun withAlpha(color: Int, alpha: Float): Int = ((255 * alpha).toInt() shl 24) or (color and 0xFFFFFF)
private companion object {
const val MII_DP = 138
/** FFL's anger, the PC's FriendsSideProfilePending. */
const val EXPRESSION_ANGER = 2
/** The PC keeps the order for the session, in a static, not as a setting. */
var order = FriendList.Order.Online
}
}
@@ -31,8 +31,8 @@ import org.wiicompiled.quest.R
* 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, a Settings page that edits
* Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now and a
* Leaderboard page with its top 50.
* Config.toml, and under Online a Rooms page that shows who plays on Retro WFC now, a
* Leaderboard page with its top 50 and a Friends page with the primary licence's friend list.
*
* 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
@@ -45,7 +45,7 @@ import org.wiicompiled.quest.R
*/
class LauncherActivity : Activity() {
private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms, Leaderboard }
private enum class Page { Home, Profiles, Patches, Miis, Settings, Rooms, Leaderboard, Friends }
/** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
@@ -58,6 +58,8 @@ class LauncherActivity : Activity() {
private lateinit var navRooms: View
private lateinit var navRoomsCount: TextView
private lateinit var navLeaderboard: View
private lateinit var navFriends: View
private lateinit var navFriendsCount: TextView
private lateinit var homePage: View
private lateinit var profilesView: View
private lateinit var patchesView: View
@@ -65,6 +67,7 @@ class LauncherActivity : Activity() {
private lateinit var settingsView: View
private lateinit var roomsView: View
private lateinit var leaderboardView: View
private lateinit var friendsView: View
private lateinit var trails: WheelTrailsView
private lateinit var playButton: View
private lateinit var playIcon: ImageView
@@ -85,6 +88,9 @@ class LauncherActivity : Activity() {
private lateinit var sidebarProfile: SidebarProfileCard
private lateinit var rooms: RoomsPage
private lateinit var leaderboard: LeaderboardPage
private lateinit var friendsPage: FriendsPage
private lateinit var friendActions: FriendActions
private lateinit var footer: SidebarFooter
/** The sidebar's count of players online (WheelWizard's UpdatePlayerCount). */
private val playersOnline: () -> Unit = {
@@ -95,6 +101,18 @@ class LauncherActivity : Activity() {
} else {
resources.getQuantityString(R.plurals.launcher_players_online, count, count)
}
friendsOnline()
}
/** The sidebar's friends online out of all of them (WheelWizard's UpdateFriendCount). */
private val friendsOnline: () -> Unit = {
val online = FriendList.onlineCount
navFriendsCount.text = getString(R.string.launcher_friends_count, online, FriendList.friends.size)
navFriendsCount.tooltipText = if (online == 0) {
getString(R.string.launcher_friends_online_none)
} else {
resources.getQuantityString(R.plurals.launcher_friends_online, online, online)
}
}
/** The games this APK carries a kit for, and the one the player picked. */
@@ -136,6 +154,8 @@ class LauncherActivity : Activity() {
navRooms = findViewById(R.id.nav_rooms)
navRoomsCount = findViewById(R.id.nav_rooms_count)
navLeaderboard = findViewById(R.id.nav_leaderboard)
navFriends = findViewById(R.id.nav_friends)
navFriendsCount = findViewById(R.id.nav_friends_count)
homePage = findViewById(R.id.page_home)
profilesView = findViewById(R.id.page_profiles)
patchesView = findViewById(R.id.page_patches)
@@ -143,6 +163,7 @@ class LauncherActivity : Activity() {
settingsView = findViewById(R.id.page_settings)
roomsView = findViewById(R.id.page_rooms)
leaderboardView = findViewById(R.id.page_leaderboard)
friendsView = findViewById(R.id.page_friends)
trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play)
playIcon = findViewById(R.id.home_play_icon)
@@ -185,15 +206,30 @@ class LauncherActivity : Activity() {
pickExport = ::openMiiExport,
)
sidebarProfile = SidebarProfileCard(this, findViewById(R.id.sidebar_profile)) { showPage(Page.Profiles) }
profilesPage = ProfilesPage(this, profilesView) { snapshot -> sidebarProfile.show(snapshot) }
rooms = RoomsPage(this, roomsView)
// The leaderboard's View Room opens the player's room on the Rooms page.
leaderboard = LeaderboardPage(this, leaderboardView) { room ->
profilesPage = ProfilesPage(this, profilesView) { snapshot ->
sidebarProfile.show(snapshot)
// Make Primary changes whose friends these are.
FriendList.show(this, snapshot)
}
friendActions = FriendActions(this, ::isGameRunning)
rooms = RoomsPage(this, roomsView, friendActions)
// The leaderboard's and friends' View Room open the player's room on the Rooms page.
leaderboard = LeaderboardPage(this, leaderboardView, friendActions) { room ->
showPage(Page.Rooms)
rooms.showRoom(room)
}
friendsPage = FriendsPage(this, friendsView, friendActions) { room ->
showPage(Page.Rooms)
rooms.showRoom(room)
}
LiveRooms.addListener(playersOnline)
FriendList.addListener(friendsOnline)
playersOnline()
// The PC's About opens its settings on the app's information.
footer = SidebarFooter(this, findViewById(android.R.id.content), openSettings = { showPage(Page.Settings) }) {
settings.select(SettingsPage.Tab.About)
showPage(Page.Settings)
}
savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
}
@@ -205,6 +241,7 @@ class LauncherActivity : Activity() {
navSettings.setOnClickListener { showPage(Page.Settings) }
navRooms.setOnClickListener { showPage(Page.Rooms) }
navLeaderboard.setOnClickListener { showPage(Page.Leaderboard) }
navFriends.setOnClickListener { showPage(Page.Friends) }
playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) }
cancel.setOnClickListener { GameSetup.cancel() }
@@ -259,11 +296,15 @@ class LauncherActivity : Activity() {
patches.attach()
// The game may have changed the save while it ran; the profiles page reads it again itself.
if (page != Page.Profiles) sidebarProfile.refresh()
// ...and the friend list, for the sidebar's count and the Friends page.
if (page != Page.Friends) FriendList.load(this)
profilesPage.setVisible(page == Page.Profiles)
// Who plays online is followed while the launcher is on screen, for the sidebar's count.
LiveRooms.start()
WheelWizardStatus.start()
rooms.setVisible(page == Page.Rooms)
leaderboard.setVisible(page == Page.Leaderboard)
friendsPage.setVisible(page == Page.Friends)
// The game's process can take a moment to go away after its activity
// closes, which would still read as running.
val runningAtResume = isGameRunning()
@@ -278,12 +319,16 @@ class LauncherActivity : Activity() {
profilesPage.setVisible(false)
rooms.setVisible(false)
leaderboard.setVisible(false)
friendsPage.setVisible(false)
LiveRooms.stop()
WheelWizardStatus.stop()
super.onPause()
}
override fun onDestroy() {
LiveRooms.removeListener(playersOnline)
FriendList.removeListener(friendsOnline)
footer.destroy()
super.onDestroy()
}
@@ -336,6 +381,7 @@ class LauncherActivity : Activity() {
navSettings.isSelected = target == Page.Settings
navRooms.isSelected = target == Page.Rooms
navLeaderboard.isSelected = target == Page.Leaderboard
navFriends.isSelected = target == Page.Friends
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
@@ -346,6 +392,8 @@ class LauncherActivity : Activity() {
rooms.setVisible(target == Page.Rooms)
leaderboardView.visibility = if (target == Page.Leaderboard) View.VISIBLE else View.GONE
leaderboard.setVisible(target == Page.Leaderboard)
friendsView.visibility = if (target == Page.Friends) View.VISIBLE else View.GONE
friendsPage.setVisible(target == Page.Friends)
if (target == Page.Home) {
trailsAway = true
}
@@ -359,8 +407,8 @@ class LauncherActivity : Activity() {
Page.Patches -> patches.refresh()
Page.Miis -> miis.refresh()
Page.Settings -> settings.refresh()
// Rooms follow Retro WFC's answers by themselves, and the leaderboard asks when shown.
Page.Rooms, Page.Leaderboard -> Unit
// Rooms follow Retro WFC's answers by themselves; the leaderboard and friends ask when shown.
Page.Rooms, Page.Leaderboard, Page.Friends -> Unit
}
}
@@ -19,13 +19,15 @@ import org.wiicompiled.quest.R
/**
* The launcher's Leaderboard page, WheelWizard's LeaderboardPage: Retro WFC's top 50
* ([Leaderboard]), the first three on a podium and the rest in a list. A player opens their actions
* ([PlayerActions]); one in a room now also has View Room, which opens it on the Rooms page
* ([viewRoom]). Like the PC, which makes the page anew each time it is opened, it asks again every
* time it is shown, through the 90-second cache it shares with the rooms.
* ([PlayerActions], Add Friend through [friends]); one in a room now also has View Room, which
* opens it on the Rooms page ([viewRoom]). Like the PC, which makes the page anew each time it is
* opened, it asks again every time it is shown, through the 90-second cache it shares with the
* rooms.
*/
class LeaderboardPage(
private val activity: Activity,
root: View,
private val friends: FriendActions,
private val viewRoom: (LiveRooms.Room) -> Unit,
) {
private val loading: View = root.findViewById(R.id.leaderboard_loading)
@@ -58,7 +60,7 @@ class LeaderboardPage(
root.findViewById<View>(R.id.leaderboard_refresh).setOnClickListener { load() }
podium.forEachIndexed { index, card ->
card.root.setOnClickListener {
rows.getOrNull(index)?.let { row -> PlayerActions.show(activity, card.root, row.friendCode, row.mii, offsetDp = 20) }
rows.getOrNull(index)?.let { row -> showActions(row, card.root, offsetDp = 20) }
}
}
}
@@ -145,6 +147,13 @@ class LeaderboardPage(
else -> activity.getString(R.string.leaderboard_rank, rank)
}
/** The player's context menu, Add Friend with the Mii and VR the leaderboard gives. */
private fun showActions(row: Leaderboard.Row, anchor: View, offsetDp: Int = 60) {
PlayerActions.show(activity, anchor, row.friendCode, row.mii, offsetDp, addFriend = {
friends.addPlayer(name(row), row.friendCode, row.mii, row.vr)
})
}
/** JoinRoom_OnClick: the room the player is in now, on the Rooms page. */
private fun joinRoom(row: Leaderboard.Row) {
val room = Leaderboard.roomOf(LiveRooms.rooms, row.friendCode)
@@ -184,7 +193,7 @@ class LeaderboardPage(
visibility = if (Leaderboard.roomOf(LiveRooms.rooms, player.friendCode) != null) View.VISIBLE else View.GONE
setOnClickListener { joinRoom(player) }
}
row.setOnClickListener { PlayerActions.show(activity, row, player.friendCode, player.mii) }
row.setOnClickListener { showActions(player, row) }
return row
}
}
@@ -62,12 +62,13 @@ object MiiImages {
}
/**
* [mii] turned as [pose], [size] pixels square, for [done] on the main thread; null when it
* cannot be drawn, such as before the Mii parts are downloaded.
* [mii] turned as [pose], [size] pixels square, with FFL's [expression] (0 its own face, 2
* anger), for [done] on the main thread; null when it cannot be drawn, such as before the Mii
* parts are downloaded.
*/
fun picture(context: Context, mii: Mii, size: Int, pose: MiiRenderer.Pose, done: (Bitmap?) -> Unit) {
fun picture(context: Context, mii: Mii, size: Int, pose: MiiRenderer.Pose, expression: Int = 0, done: (Bitmap?) -> Unit) {
val copy = mii.copy()
val key = "mii:$size:${pose.key}:${copy.lookKey()}"
val key = "mii:$size:${pose.key}:$expression:${copy.lookKey()}"
cache.get(key)?.let {
done(it)
return
@@ -76,7 +77,7 @@ object MiiImages {
val generation = cacheGeneration.get()
renderers.execute {
val bitmap = draw(app, key, generation) { resource ->
MiiRenderer.render(resource, copy, size, pose, MiiRenderResource.bodies(app))
MiiRenderer.render(resource, copy, size, pose, MiiRenderResource.bodies(app), expression = expression)
}
main.post { done(bitmap) }
}
@@ -63,7 +63,8 @@ object MiiRenderer {
pose: Pose = Pose.FRONT,
bodies: MiiBodies? = null,
fullBody: Boolean = false,
): IntArray = render(resource, FflCharInfo.of(mii), size, pose = pose, bodies = bodies, fullBody = fullBody)
expression: Int = 0,
): IntArray = render(resource, FflCharInfo.of(mii), size, expression, pose, bodies, fullBody)
@Throws(IOException::class)
internal fun render(
@@ -10,18 +10,28 @@ import android.widget.Toast
import org.wiicompiled.quest.R
/**
* The context menu WheelWizard gives a player in a room and on the leaderboard, opened here by a
* tap on them: copy their friend code, see their Mii, or their profile. The PC's Add Friend waits
* for the Friends page, which reads and writes the save's friend list.
* The context menu WheelWizard gives a player in a room, on the leaderboard and in the friend
* list, opened here by a tap on them: copy their friend code, see their Mii, add them as a friend
* ([addFriend], rooms and leaderboard), their profile, and remove them ([removeFriend], friends).
*/
object PlayerActions {
/** Opens the menu under [anchor], [offsetDp] from its start. */
fun show(activity: Activity, anchor: View, friendCode: String, mii: Mii?, offsetDp: Int = 60) {
val actions = listOf(
fun show(
activity: Activity,
anchor: View,
friendCode: String,
mii: Mii?,
offsetDp: Int = 60,
addFriend: (() -> Unit)? = null,
removeFriend: (() -> Unit)? = null,
) {
val actions = listOfNotNull(
R.string.room_copy_friend_code to { copyFriendCode(activity, friendCode) },
R.string.room_view_mii to { viewMii(activity, mii) },
addFriend?.let { R.string.friends_add to it },
R.string.room_view_profile to { if (friendCode.isNotEmpty()) PlayerProfileDialog.show(activity, friendCode) },
removeFriend?.let { R.string.friends_remove to it },
)
ListPopupWindow(activity).apply {
anchorView = anchor
@@ -112,7 +112,7 @@ object ProfileStore {
val pose = MiiRenderer.Pose.SIDE
val local = snapshot.miis[license.miiId]
if (local != null && drawable) {
MiiImages.picture(context, local, size, pose, done)
MiiImages.picture(context, local, size, pose, done = done)
return
}
val friendCode = license.friendCode
@@ -0,0 +1,216 @@
package org.wiicompiled.quest.launcher
import java.security.MessageDigest
import java.util.zip.CRC32
/**
* The friend lists of a Mario Kart Wii save, read and changed as WheelWizard's GameLicenseService
* does (ParseFriends, AddFriend, RemoveFriend). Each licence (RKPD block) keeps 30 friend slots of
* 0x1C0 bytes at 0x56D0, and at 0x8B50 twelve bytes more per slot for the Wii's friend
* registration. A slot holds the friend's profile ID, the wins and losses against them, their VR
* and BR, and their Mii. The whole save is checked by a CRC-32 over its first 0x27FFC bytes,
* stored right after them, which every change here writes again.
*/
object RksysFriends {
const val MAX = 30
/** A whole save's size; the PC changes nothing in a shorter file. */
const val SAVE_SIZE = 0x2BC000
private const val MAGIC = "RKSD0006"
private const val LICENSE_MAGIC = "RKPD"
private const val LICENSE_SIZE = 0x8CC0
private const val CRC_OFFSET = 0x27FFC
private const val SLOTS_OFFSET = 0x56D0
private const val SLOT_SIZE = 0x1C0
private const val REGISTRATION_OFFSET = 0x8B50
private const val REGISTRATION_SIZE = 0x0C
private const val MII_OFFSET = 0x1A
/** A friend added here and not yet added back: bit 0 of the slot's state without bit 1. */
private const val STATE_ADDED = 0x0001
private const val CONTROL_INVALID = 0x00
private const val CONTROL_FRIEND_KEY = 0x10
private const val DEFAULT_COUNTRY = 0xFF
private const val DEFAULT_REGION = 0xFF
/** The ten records after the slot's fixed part start as "none" (0xFFFFFFFF, every 8 bytes). */
private const val RECORDS_OFFSET = 0x70
private const val RECORDS = 10
/** A friend in a licence's list (FriendProfile). */
data class Friend(
val slot: Int,
val profileId: Long,
val friendCode: String,
val mii: Mii,
val vr: Int,
val br: Int,
val wins: Int,
val losses: Int,
val countryCode: Int,
val regionId: Int,
/** Added from this licence but not yet added back (the PC's IsPending). */
val isPending: Boolean,
) {
/** The Mii's name as the PC shows it; a NUL inside one ("matt\0ias") is not drawn there. */
val name: String get() = mii.name.replace("\u0000", "")
}
/** Why a friend cannot be added or removed, with the PC's messages in the launcher's strings. */
enum class Problem { Empty, NotTwelveDigits, Invalid, Own, Duplicate, Full, NoLicense, NotFound, NotASave, CannotWrite }
/**
* ParseFriends: the friends of licence [slot] in [save] (the licences are enough), in slot
* order. A slot without a Mii is empty, and one whose Mii cannot be read is left out.
*/
fun parse(save: ByteArray, slot: Int): List<Friend> {
val base = licenseBase(slot)
if (save.size < base + LICENSE_SIZE || String(save, base, LICENSE_MAGIC.length, Charsets.US_ASCII) != LICENSE_MAGIC) return emptyList()
return (0 until MAX).mapNotNull { index ->
val offset = base + SLOTS_OFFSET + index * SLOT_SIZE
if (!hasMii(save, offset)) return@mapNotNull null
val mii = runCatching { MiiData.parse(save.copyOfRange(offset + MII_OFFSET, offset + MII_OFFSET + MiiData.SIZE)) }.getOrNull()
?: return@mapNotNull null
val state = u16(save, offset + 0x10) and 0x0003
val control = save[base + REGISTRATION_OFFSET + index * REGISTRATION_SIZE + 2].toInt() and 0xFF
val profileId = u32(save, offset + 0x04)
Friend(
slot = index,
profileId = profileId,
friendCode = RksysProfiles.friendCode(profileId),
mii = mii,
vr = u16(save, offset + 0x16),
br = u16(save, offset + 0x18),
wins = u16(save, offset + 0x14),
losses = u16(save, offset + 0x12),
countryCode = save[offset + 0x68].toInt() and 0xFF,
regionId = save[offset + 0x69].toInt() and 0xFF,
// Old one-sided entries left the control byte at 0 and are pending too.
isPending = state == STATE_ADDED && (control == CONTROL_FRIEND_KEY || control == CONTROL_INVALID),
)
}
}
/**
* AddFriend: writes [friendCode] (as [normalize] gives it) with the 74 bytes of its Mii into the
* first empty slot of licence [slot] as a request the friend has not yet answered, and the
* save's CRC again. Null when it was added, else why not.
*/
fun add(save: ByteArray, slot: Int, friendCode: String, mii: ByteArray, vr: Int, br: Int = 5000): Problem? {
if (!isSave(save)) return Problem.NotASave
val profileId = profileId(friendCode)
if (profileId == 0L) return Problem.Invalid
val base = licenseBase(slot)
if (String(save, base, LICENSE_MAGIC.length, Charsets.US_ASCII) != LICENSE_MAGIC) return Problem.NoLicense
val own = u32(save, base + 0x5C)
if (own == 0L) return Problem.NoLicense
if (own == profileId) return Problem.Own
if (parse(save, slot).any { it.profileId == profileId }) return Problem.Duplicate
val index = (0 until MAX).firstOrNull { isEmpty(save, base + SLOTS_OFFSET + it * SLOT_SIZE) } ?: return Problem.Full
require(mii.size == MiiData.SIZE) { "A Mii is ${MiiData.SIZE} bytes." }
val offset = base + SLOTS_OFFSET + index * SLOT_SIZE
val registration = base + REGISTRATION_OFFSET + index * REGISTRATION_SIZE
save.fill(0, offset, offset + SLOT_SIZE)
save.fill(0, registration, registration + REGISTRATION_SIZE)
// The friend's key: the friend code's upper half, then the profile ID.
val code = friendCode.filter { it in '0'..'9' }.toLong()
putU32(save, offset, code ushr 32)
putU32(save, offset + 0x04, profileId)
putU16(save, offset + 0x10, STATE_ADDED)
putU16(save, offset + 0x16, vr.coerceIn(0, 0xFFFF))
putU16(save, offset + 0x18, br.coerceIn(0, 0xFFFF))
mii.copyInto(save, offset + MII_OFFSET)
putU16(save, offset + 0x64, MiiDatabase.crc16(mii, 0, mii.size))
save[offset + 0x66] = index.toByte()
save[offset + 0x68] = DEFAULT_COUNTRY.toByte()
save[offset + 0x69] = DEFAULT_REGION.toByte()
for (record in 0 until RECORDS) putU32(save, offset + RECORDS_OFFSET + record * 8, 0xFFFFFFFFL)
// The request the Wii sends until the friend adds this licence back.
save[registration + 2] = CONTROL_FRIEND_KEY.toByte()
putU32(save, registration + 4, profileId)
fixCrc(save)
return null
}
/** RemoveFriend: empties the slot of [profileId] in licence [slot]; false when it is not there. */
fun remove(save: ByteArray, slot: Int, profileId: Long): Boolean {
if (!isSave(save) || profileId == 0L) return false
val base = licenseBase(slot)
val index = (0 until MAX).firstOrNull { u32(save, base + SLOTS_OFFSET + it * SLOT_SIZE + 0x04) == profileId } ?: return false
val offset = base + SLOTS_OFFSET + index * SLOT_SIZE
val registration = base + REGISTRATION_OFFSET + index * REGISTRATION_SIZE
save.fill(0, offset, offset + SLOT_SIZE)
save.fill(0, registration, registration + REGISTRATION_SIZE)
fixCrc(save)
return true
}
fun isSave(save: ByteArray): Boolean = save.size >= SAVE_SIZE && String(save, 0, MAGIC.length, Charsets.US_ASCII) == MAGIC
/** FixRksysCrc: CRC-32 over everything before 0x27FFC, big-endian at 0x27FFC. */
fun fixCrc(save: ByteArray) {
val crc = CRC32().apply { update(save, 0, CRC_OFFSET) }.value
putU32(save, CRC_OFFSET, crc)
}
/**
* NormalizeFriendCode: the twelve digits of [text] as 0000-0000-0000, or why it is not a friend
* code, as the PC checks one typed in.
*/
fun normalize(text: String): Result {
if (text.isBlank()) return Result(problem = Problem.Empty)
val digits = text.filter { it in '0'..'9' }
if (digits.length != 12) return Result(problem = Problem.NotTwelveDigits)
val formatted = "${digits.substring(0, 4)}-${digits.substring(4, 8)}-${digits.substring(8)}"
if (profileId(formatted) == 0L) return Result(problem = Problem.Invalid)
return Result(friendCode = formatted)
}
class Result(val friendCode: String? = null, val problem: Problem? = null)
/**
* FriendCodeToProfileId: the profile ID a friend code stands for, its lower 32 bits, once its
* upper bits match the checksum the ID gives; 0 for anything else.
*/
fun profileId(friendCode: String): Long {
val digits = friendCode.replace("-", "")
if (digits.length != 12 || !digits.all { it in '0'..'9' }) return 0L
val value = digits.toLong()
val profileId = value and 0xFFFFFFFFL
if (profileId == 0L) return 0L
return if (value ushr 32 == checksum(profileId)) profileId else 0L
}
/** ProfileIdToFriendCode's checksum: the top seven bits of the MD5 of the ID (little-endian) and "JCMR". */
private fun checksum(profileId: Long): Long {
val id = profileId.toInt()
val input = byteArrayOf(id.toByte(), (id ushr 8).toByte(), (id ushr 16).toByte(), (id ushr 24).toByte(), 0x4A, 0x43, 0x4D, 0x52)
return ((MessageDigest.getInstance("MD5").digest(input)[0].toInt() and 0xFF) ushr 1).toLong()
}
private fun licenseBase(slot: Int): Int = MAGIC.length + slot * LICENSE_SIZE
private fun hasMii(save: ByteArray, slotOffset: Int): Boolean =
(slotOffset + MII_OFFSET until slotOffset + MII_OFFSET + MiiData.SIZE).any { save[it] != 0.toByte() }
/** IsFriendSlotEmpty: no profile ID, no state and no Mii. */
private fun isEmpty(save: ByteArray, slotOffset: Int): Boolean =
u32(save, slotOffset + 0x04) == 0L && u16(save, slotOffset + 0x10) and 0x0003 == 0 && !hasMii(save, slotOffset)
private fun u16(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun u32(data: ByteArray, offset: Int): Long =
((data[offset].toLong() and 0xFF) shl 24) or ((data[offset + 1].toLong() and 0xFF) shl 16) or
((data[offset + 2].toLong() and 0xFF) shl 8) or (data[offset + 3].toLong() and 0xFF)
private fun putU16(data: ByteArray, offset: Int, value: Int) {
data[offset] = (value ushr 8).toByte()
data[offset + 1] = value.toByte()
}
private fun putU32(data: ByteArray, offset: Int, value: Long) {
data[offset] = (value ushr 24).toByte()
data[offset + 1] = (value ushr 16).toByte()
data[offset + 2] = (value ushr 8).toByte()
data[offset + 3] = value.toByte()
}
}
@@ -10,9 +10,8 @@ import java.security.MessageDigest
* (after kazuki-4ys' FaceThief and https://wiki.tockdom.com/wiki/Rksys.dat): after the RKSD0006
* magic come four RKPD blocks, one per licence, each holding its Mii's name and ID, the profile ID
* its friend code derives from, its VR and BR, and race counts. Retro Rewind keeps the ratings it plays
* with in Pulsar's RRRating.pul, which outranks the save's own when it knows the profile.
*
* Nothing here writes a save: the profiles page only shows what the game recorded.
* with in Pulsar's RRRating.pul, which outranks the save's own when it knows the profile. Each
* licence's friend list is [RksysFriends]', which also changes it.
*/
object RksysProfiles {
@@ -55,6 +54,7 @@ object RksysProfiles {
val wins: Long,
/** The licence's Mii in the Mii database; a guest Mii's (0x80000001 and on) is in none. */
val miiId: Long = 0,
val friends: List<RksysFriends.Friend> = emptyList(),
)
/** A rating from RRRating.pul, already as the game shows it (60.50 is 6050). */
@@ -81,6 +81,7 @@ object RksysProfiles {
races = u32(save, base + RACES_OFFSET),
wins = u32(save, base + WINS_OFFSET),
miiId = u32(save, base + MII_ID_OFFSET),
friends = RksysFriends.parse(save, slot),
)
}
}
@@ -19,9 +19,10 @@ 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 ([PlayerActions]): copy their
* friend code, see their Mii, or their profile. As on the PC, nothing here joins a room.
* friend code, see their Mii, add them as a friend ([friends]), or their profile. As on the PC,
* nothing here joins a room.
*/
class RoomsPage(private val activity: Activity, root: View) {
class RoomsPage(private val activity: Activity, root: View, private val friends: FriendActions) {
private val main: View = root.findViewById(R.id.rooms_main)
private val search: EditText = root.findViewById(R.id.rooms_search)
@@ -42,7 +43,11 @@ class RoomsPage(private val activity: Activity, root: View) {
private val roomAdapter = RoomAdapter()
private val searchAdapter = PlayerAdapter { player -> roomOf(player)?.let(::openRoom) }
private val detailsAdapter = PlayerAdapter { player, row -> PlayerActions.show(activity, row, player.friendCode, player.mii) }
private val detailsAdapter = PlayerAdapter { player, row ->
PlayerActions.show(activity, row, player.friendCode, player.mii, addFriend = {
friends.addPlayer(player.name, player.friendCode, player.mii, player.vr)
})
}
private var visible = false
private var query = ""
@@ -403,6 +403,7 @@ class SettingsPage(
info(R.string.about_credit_title_mod_browser, activity.getString(R.string.about_credit_mod_browser), stacked = true)
info(R.string.about_credit_title_profiles, activity.getString(R.string.about_credit_profiles), stacked = true)
info(R.string.about_credit_title_miis, activity.getString(R.string.about_credit_miis), stacked = true)
info(R.string.about_credit_title_online, activity.getString(R.string.about_credit_online), stacked = true)
if (BuildConfig.ON_DEVICE_BUILD) {
info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true)
}
@@ -0,0 +1,163 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.ListPopupWindow
import android.widget.TextView
import android.widget.Toast
import org.wiicompiled.quest.R
/**
* WheelWizard's sidebar bottom bar (Layout.axaml's SidebarBottomBar): the Wheel Wizard team's
* status ([WheelWizardStatus]) as an icon whose tip is its message, the info menu (who made this,
* About, and the Discord, GitHub and support links), Settings, and the version.
*/
class SidebarFooter(
private val activity: Activity,
root: View,
private val openSettings: () -> Unit,
private val openAbout: () -> Unit,
) {
private val statusIcon: ImageView = root.findViewById(R.id.sidebar_status)
private val info: View = root.findViewById(R.id.sidebar_info)
private val statusChanged: () -> Unit = { showStatus() }
init {
info.setOnClickListener { showInfo() }
root.findViewById<View>(R.id.sidebar_settings).setOnClickListener { openSettings() }
// The PC only shows the message under the pointer; a tap shows it too.
statusIcon.setOnClickListener { statusIcon.tooltipText?.let { Toast.makeText(activity, it, Toast.LENGTH_LONG).show() } }
WheelWizardStatus.addListener(statusChanged)
showStatus()
}
fun destroy() = WheelWizardStatus.removeListener(statusChanged)
/** UpdateLiveAlert. */
private fun showStatus() {
val status = WheelWizardStatus.status
if (status == null || !status.visible) {
statusIcon.visibility = View.GONE
return
}
statusIcon.visibility = View.VISIBLE
statusIcon.tooltipText = if (status.failed) activity.getString(R.string.sidebar_status_failed) else status.message
statusIcon.contentDescription = statusIcon.tooltipText
val custom = status.icon?.takeIf { it.isNotEmpty() }?.let { data -> runCatching { SvgPath.toPath(SvgPath.parse(data)) }.getOrNull() }
if (custom != null) {
// An icon of its own, in its colour or white.
val color = status.color?.let { runCatching { Color.parseColor(it) }.getOrNull() } ?: Color.WHITE
statusIcon.imageTintList = null
statusIcon.setImageDrawable(SvgPath.IconDrawable(custom, color))
return
}
val (icon, color) = when (status.variant) {
WheelWizardStatus.Variant.Warning -> R.drawable.ic_warning_tip to R.color.warning_500
WheelWizardStatus.Variant.Error -> R.drawable.ic_error_tip to R.color.danger_500
WheelWizardStatus.Variant.Success -> R.drawable.ic_success_tip to R.color.primary_300
WheelWizardStatus.Variant.Info -> R.drawable.ic_info to R.color.neutral_200
WheelWizardStatus.Variant.Question -> R.drawable.ic_question_tip to R.color.warning_600
WheelWizardStatus.Variant.Party -> R.drawable.ic_star to R.color.danger_300
// A custom icon that could not be read leaves an empty space, as the PC's would.
else -> {
statusIcon.setImageDrawable(null)
return
}
}
statusIcon.setImageResource(icon)
statusIcon.imageTintList = activity.getColorStateList(color)
}
/** SidebarInfoContextMenu. */
private fun showInfo() {
val entries = listOf(
Entry.Header,
Entry.Divider,
Entry.Action(R.string.sidebar_about, R.drawable.ic_info) { openAbout() },
Entry.Action(R.string.sidebar_discord, R.drawable.ic_discord) { open(DISCORD_URL) },
Entry.Action(R.string.sidebar_github, R.drawable.ic_github) { open(GITHUB_URL) },
Entry.Action(R.string.sidebar_support, R.drawable.ic_coffee) { open(SUPPORT_URL) },
)
ListPopupWindow(activity).apply {
anchorView = info
setAdapter(MenuAdapter(entries))
setBackgroundDrawable(activity.getDrawable(R.drawable.bg_dropdown_popup))
width = PatchesWidgets.dp(activity, 220)
isModal = true
setOnItemClickListener { _, _, position, _ ->
val entry = entries[position] as? Entry.Action ?: return@setOnItemClickListener
dismiss()
entry.run()
}
show()
}
}
private fun open(url: String) {
try {
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.browser_no_browser, Toast.LENGTH_LONG).show()
}
}
private sealed class Entry {
object Header : Entry()
object Divider : Entry()
class Action(val label: Int, val icon: Int, val run: () -> Unit) : Entry()
}
private inner class MenuAdapter(private val entries: List<Entry>) : BaseAdapter() {
override fun getCount(): Int = entries.size
override fun getItem(position: Int): Any = entries[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getViewTypeCount(): Int = 3
override fun getItemViewType(position: Int): Int = when (entries[position]) {
Entry.Header -> 0
Entry.Divider -> 1
is Entry.Action -> 2
}
override fun areAllItemsEnabled(): Boolean = false
override fun isEnabled(position: Int): Boolean = entries[position] is Entry.Action
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View = when (val entry = entries[position]) {
// The PC's disabled "Made by" header.
Entry.Header -> (convertView as? TextView ?: TextView(activity)).apply {
setText(R.string.sidebar_made_by)
setTextColor(activity.getColor(R.color.neutral_400))
textSize = 12f
setPadding(dp(12), dp(8), dp(12), dp(6))
}
Entry.Divider -> convertView ?: View(activity).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(9))
background = activity.getDrawable(R.drawable.bg_menu_divider)
}
is Entry.Action -> ((convertView as? TextView) ?: activity.layoutInflater.inflate(R.layout.item_dropdown_popup, parent, false) as TextView).apply {
setText(entry.label)
val size = dp(16)
val icon = activity.getDrawable(entry.icon)?.mutate()?.apply {
setBounds(0, 0, size, size)
setTint(activity.getColor(R.color.neutral_300))
}
setCompoundDrawablesRelative(icon, null, null, null)
compoundDrawablePadding = dp(10)
}
}
}
private fun dp(value: Int): Int = PatchesWidgets.dp(activity, value)
private companion object {
/** The links the user chose: this project's repository, and Wheel Wizard's Discord and Ko-fi as on the PC. */
const val GITHUB_URL = "https://github.com/iChris4/Wiicompiled_VR"
const val DISCORD_URL = "https://discord.gg/vZ7T2wJnsq"
const val SUPPORT_URL = "https://ko-fi.com/wheelwizard"
}
}
@@ -0,0 +1,283 @@
package org.wiicompiled.quest.launcher
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan
/**
* SVG path data, as Wheel Wizard's status can carry an icon of its own (Avalonia's
* Geometry.Parse on the PC): every command of the path mini-language, relative or absolute, with
* arcs turned into cubic curves. [parse] is plain Kotlin, so it is tested apart from Android.
*/
object SvgPath {
sealed class Segment {
data class Move(val x: Float, val y: Float) : Segment()
data class Line(val x: Float, val y: Float) : Segment()
data class Cubic(val x1: Float, val y1: Float, val x2: Float, val y2: Float, val x: Float, val y: Float) : Segment()
data class Quad(val x1: Float, val y1: Float, val x: Float, val y: Float) : Segment()
object Close : Segment()
}
/** The path's segments in absolute coordinates; throws IllegalArgumentException on bad data. */
fun parse(data: String): List<Segment> {
val reader = Reader(data.trim().removePrefix("F0").removePrefix("F1"))
val out = ArrayList<Segment>()
var x = 0f
var y = 0f
var startX = 0f
var startY = 0f
// The last control point, for S and T, and which kind of curve set it.
var controlX = 0f
var controlY = 0f
var last = ' '
var command = ' '
while (true) {
reader.skipSeparators()
if (reader.done) break
val next = reader.peek()
if (next.isLetter()) {
command = next
reader.advance()
} else {
require(command != ' ' && command.lowercaseChar() != 'z') { "Path data must start with a command" }
// Numbers after a move are lines.
if (command == 'M') command = 'L' else if (command == 'm') command = 'l'
}
val relative = command.isLowerCase()
val baseX = if (relative) x else 0f
val baseY = if (relative) y else 0f
when (command.uppercaseChar()) {
'M' -> {
x = baseX + reader.number()
y = baseY + reader.number()
startX = x
startY = y
out += Segment.Move(x, y)
}
'L' -> {
x = baseX + reader.number()
y = baseY + reader.number()
out += Segment.Line(x, y)
}
'H' -> {
x = baseX + reader.number()
out += Segment.Line(x, y)
}
'V' -> {
y = baseY + reader.number()
out += Segment.Line(x, y)
}
'C' -> {
val x1 = baseX + reader.number()
val y1 = baseY + reader.number()
controlX = baseX + reader.number()
controlY = baseY + reader.number()
x = baseX + reader.number()
y = baseY + reader.number()
out += Segment.Cubic(x1, y1, controlX, controlY, x, y)
}
'S' -> {
val x1 = if (last == 'C' || last == 'S') 2 * x - controlX else x
val y1 = if (last == 'C' || last == 'S') 2 * y - controlY else y
controlX = baseX + reader.number()
controlY = baseY + reader.number()
x = baseX + reader.number()
y = baseY + reader.number()
out += Segment.Cubic(x1, y1, controlX, controlY, x, y)
}
'Q' -> {
controlX = baseX + reader.number()
controlY = baseY + reader.number()
x = baseX + reader.number()
y = baseY + reader.number()
out += Segment.Quad(controlX, controlY, x, y)
}
'T' -> {
controlX = if (last == 'Q' || last == 'T') 2 * x - controlX else x
controlY = if (last == 'Q' || last == 'T') 2 * y - controlY else y
x = baseX + reader.number()
y = baseY + reader.number()
out += Segment.Quad(controlX, controlY, x, y)
}
'A' -> {
val rx = reader.number()
val ry = reader.number()
val rotation = reader.number()
val large = reader.flag()
val sweep = reader.flag()
val endX = baseX + reader.number()
val endY = baseY + reader.number()
arc(out, x, y, rx, ry, rotation, large, sweep, endX, endY)
x = endX
y = endY
}
'Z' -> {
out += Segment.Close
x = startX
y = startY
}
else -> throw IllegalArgumentException("Unknown path command $command")
}
last = command.uppercaseChar()
}
return out
}
/** The SVG spec's endpoint arc (appendix F.6), as cubic curves of at most a quarter turn each. */
private fun arc(
out: MutableList<Segment>,
x0: Float, y0: Float, rxIn: Float, ryIn: Float, rotationDegrees: Float,
large: Boolean, sweep: Boolean, x: Float, y: Float,
) {
if (x0 == x && y0 == y) return
var rx = abs(rxIn.toDouble())
var ry = abs(ryIn.toDouble())
if (rx == 0.0 || ry == 0.0) {
out += Segment.Line(x, y)
return
}
val phi = rotationDegrees * PI / 180
val cosPhi = cos(phi)
val sinPhi = sin(phi)
val dx = (x0 - x) / 2.0
val dy = (y0 - y) / 2.0
val x1p = cosPhi * dx + sinPhi * dy
val y1p = -sinPhi * dx + cosPhi * dy
// Radii too small to reach the end grow until they do.
val lambda = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry)
if (lambda > 1) {
rx *= sqrt(lambda)
ry *= sqrt(lambda)
}
val numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
val denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
var factor = sqrt(maxOf(0.0, numerator / denominator))
if (large == sweep) factor = -factor
val cxp = factor * rx * y1p / ry
val cyp = -factor * ry * x1p / rx
val cx = cosPhi * cxp - sinPhi * cyp + (x0 + x) / 2.0
val cy = sinPhi * cxp + cosPhi * cyp + (y0 + y) / 2.0
val theta1 = atan2((y1p - cyp) / ry, (x1p - cxp) / rx)
var delta = atan2((-y1p - cyp) / ry, (-x1p - cxp) / rx) - theta1
if (sweep && delta < 0) delta += 2 * PI else if (!sweep && delta > 0) delta -= 2 * PI
val pieces = ceil(abs(delta) / (PI / 2)).toInt().coerceAtLeast(1)
val step = delta / pieces
val k = 4.0 / 3.0 * tan(step / 4)
var angle = theta1
repeat(pieces) {
val cos1 = cos(angle)
val sin1 = sin(angle)
val cos2 = cos(angle + step)
val sin2 = sin(angle + step)
fun px(u: Double, v: Double) = (cx + rx * u * cosPhi - ry * v * sinPhi).toFloat()
fun py(u: Double, v: Double) = (cy + rx * u * sinPhi + ry * v * cosPhi).toFloat()
out += Segment.Cubic(
px(cos1 - k * sin1, sin1 + k * cos1), py(cos1 - k * sin1, sin1 + k * cos1),
px(cos2 + k * sin2, sin2 - k * cos2), py(cos2 + k * sin2, sin2 - k * cos2),
px(cos2, sin2), py(cos2, sin2),
)
angle += step
}
}
fun toPath(segments: List<Segment>): Path = Path().apply {
segments.forEach { segment ->
when (segment) {
is Segment.Move -> moveTo(segment.x, segment.y)
is Segment.Line -> lineTo(segment.x, segment.y)
is Segment.Cubic -> cubicTo(segment.x1, segment.y1, segment.x2, segment.y2, segment.x, segment.y)
is Segment.Quad -> quadTo(segment.x1, segment.y1, segment.x, segment.y)
Segment.Close -> close()
}
}
}
/** A PathIcon: the path filled in [color], scaled to fit its bounds and centred, as Avalonia stretches it. */
class IconDrawable(private val source: Path, color: Int) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }
private val fitted = Path()
override fun onBoundsChange(bounds: android.graphics.Rect) {
val extent = RectF().also { source.computeBounds(it, true) }
fitted.reset()
if (extent.width() <= 0f || extent.height() <= 0f || bounds.isEmpty) return
val scale = minOf(bounds.width() / extent.width(), bounds.height() / extent.height())
val matrix = Matrix().apply {
setTranslate(-extent.centerX(), -extent.centerY())
postScale(scale, scale)
postTranslate(bounds.exactCenterX(), bounds.exactCenterY())
}
source.transform(matrix, fitted)
}
override fun draw(canvas: Canvas) = canvas.drawPath(fitted, paint)
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
private class Reader(private val text: String) {
private var index = 0
val done: Boolean get() = index >= text.length
fun peek(): Char = text[index]
fun advance() {
index++
}
fun skipSeparators() {
while (index < text.length && (text[index].isWhitespace() || text[index] == ',')) index++
}
fun number(): Float {
skipSeparators()
val start = index
if (index < text.length && (text[index] == '+' || text[index] == '-')) index++
var dot = false
while (index < text.length) {
val c = text[index]
when {
c.isDigit() -> index++
c == '.' && !dot -> {
dot = true
index++
}
(c == 'e' || c == 'E') && index > start -> {
index++
if (index < text.length && (text[index] == '+' || text[index] == '-')) index++
}
else -> break
}
}
require(index > start) { "Expected a number at $start" }
return text.substring(start, index).toFloat()
}
/** Arc flags are one digit each, and may run together ("a5 5 0 015 5"). */
fun flag(): Boolean {
skipSeparators()
require(index < text.length && (text[index] == '0' || text[index] == '1')) { "Expected an arc flag at $index" }
return text[index++] == '1'
}
}
}
@@ -3,7 +3,9 @@ package org.wiicompiled.quest.launcher
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
/**
@@ -13,19 +15,31 @@ import android.widget.LinearLayout
* taps that begin on it.
*/
class TapRow(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
private val taps = Taps(this)
override fun onInterceptTouchEvent(event: MotionEvent): Boolean = taps.intercept(event)
}
/** A [TapRow] whose children are layered, such as the Friends page's cards. */
class TapFrame(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
private val taps = Taps(this)
override fun onInterceptTouchEvent(event: MotionEvent): Boolean = taps.intercept(event)
}
private class Taps(private val row: ViewGroup) {
/** The gesture began on a button inside the row, which then has all of it. */
private var onButton = false
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) onButton = buttonAt(this, event.x, event.y)
fun intercept(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) onButton = buttonAt(row, event.x, event.y)
return !onButton
}
private fun buttonAt(group: ViewGroup, x: Float, y: Float): Boolean {
for (index in group.childCount - 1 downTo 0) {
val child = group.getChildAt(index)
if (child.visibility != VISIBLE) continue
if (child.visibility != View.VISIBLE) continue
val childX = x + group.scrollX - child.left
val childY = y + group.scrollY - child.top
if (childX < 0 || childY < 0 || childX >= child.width || childY >= child.height) continue
@@ -0,0 +1,126 @@
package org.wiicompiled.quest.launcher
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import org.json.JSONException
import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig
/**
* The Wheel Wizard team's status message (WhWzStatusManager), such as "Unstable servers!", which
* the sidebar shows beside the version: WheelWizard-Data's status.json, asked every 90 seconds
* while the launcher is on screen. As on the PC, a status that cannot be had is shown as an
* error ([Status.failed]).
*/
object WheelWizardStatus {
private const val TAG = "WiiCompiledLauncher"
private const val URL_STATUS = "https://raw.githubusercontent.com/TeamWheelWizard/WheelWizard-Data/main/status.json"
private const val REFRESH_MS = 90_000L
private const val TIMEOUT_MS = 20_000
/** WhWzStatusVariant: the preset icons. */
enum class Variant { None, Warning, Error, Success, Info, Party, Question }
/**
* WhWzStatus: a preset [variant], or an [icon] of its own (SVG path data) in [color], and the
* [message] its tip shows; [failed] when there was none to read.
*/
class Status(
val variant: Variant?,
val message: String,
val icon: String? = null,
val color: String? = null,
val failed: Boolean = false,
) {
/** UpdateLiveAlert shows the icon for a variant other than None, or an icon of its own. */
val visible: Boolean get() = (variant != null && variant != Variant.None) || !icon.isNullOrEmpty()
}
private val main by lazy { Handler(Looper.getMainLooper()) }
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "WhWzStatus").apply { isDaemon = true } }
private val listeners = LinkedHashSet<() -> Unit>()
private var running = false
/** The last status read, null until the first answer; main thread only. */
var status: Status? = null
private set
private val ticker = object : Runnable {
override fun run() {
fetch()
main.postDelayed(this, REFRESH_MS)
}
}
fun addListener(listener: () -> Unit) {
listeners += listener
}
fun removeListener(listener: () -> Unit) {
listeners -= listener
}
fun start() {
if (running) return
running = true
main.post(ticker)
}
fun stop() {
running = false
main.removeCallbacks(ticker)
}
private fun fetch() {
worker.execute {
val fresh = try {
parse(download())
} catch (e: IOException) {
Log.i(TAG, "Wheel Wizard's status is unavailable: ${e.message}")
Status(Variant.Error, "", failed = true)
}
main.post {
status = fresh
listeners.toList().forEach { it() }
}
}
}
/**
* status.json as the PC reads it: its message is required and its variant, when there, one of
* the known names in any case; anything else is no status, which the PC shows as an error.
*/
fun parse(json: String): Status = try {
val root = JSONObject(json)
val message = root.opt("message") as? String ?: throw IOException("Wheel Wizard's status has no message.")
val variant = when (val name = root.opt("variant")) {
null, JSONObject.NULL -> null
is String -> Variant.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
?: throw IOException("Unknown status variant $name")
else -> throw IOException("Unknown status variant $name")
}
Status(variant, message, icon = root.opt("icon") as? String, color = root.opt("color") as? String)
} catch (e: JSONException) {
throw IOException("Wheel Wizard's status cannot be read.", e)
}
private fun download(): String {
val connection = (URL(URL_STATUS).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("User-Agent", "WiiCompiledVR-Quest/${BuildConfig.VERSION_NAME}")
}
try {
val code = connection.responseCode
if (code !in 200..299) throw IOException("GitHub answered $code.")
return connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The sidebar bottom bar's info and Settings icons, lighter under the pointer, as on the PC. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/neutral_300" android:state_hovered="true" />
<item android:color="@color/neutral_300" android:state_pressed="true" />
<item android:color="@color/neutral_500" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A FriendsListItem card; its outline, green or yellow, is a foreground FriendsPage sets. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FriendsListItem's fade over the Mii's strip: the card's colour rising over the last 22% of it,
to the PC's opacity mask of #DF. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="270"
android:centerColor="#00363944"
android:centerY="0.78"
android:endColor="#DF363944"
android:startColor="#00363944" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A menu's separator: a 1 dp line with room above and below. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="8dp"
android:insetTop="4dp"
android:insetRight="8dp"
android:insetBottom="4dp">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_600" />
</shape>
</inset>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's CoffeeIcon 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="M88 0C74.7 0 64 10.7 64 24c0 38.9 23.4 59.4 39.1 73.1l1.1 1C120.5 112.3 128 119.9 128 136c0 13.3 10.7 24 24 24s24-10.7 24-24c0-38.9-23.4-59.4-39.1-73.1l-1.1-1C119.5 47.7 112 40.1 112 24c0-13.3-10.7-24-24-24zM32 192c-17.7 0-32 14.3-32 32L0 416c0 53 43 96 96 96l192 0c53 0 96-43 96-96l16 0c61.9 0 112-50.1 112-112s-50.1-112-112-112l-48 0L32 192zm352 64l16 0c26.5 0 48 21.5 48 48s-21.5 48-48 48l-16 0 0-96zM224 24c0-13.3-10.7-24-24-24s-24 10.7-24 24c0 38.9 23.4 59.4 39.1 73.1l1.1 1C232.5 112.3 240 119.9 240 136c0 13.3 10.7 24 24 24s24-10.7 24-24c0-38.9-23.4-59.4-39.1-73.1l-1.1-1C231.5 47.7 224 40.1 224 24z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's DiscordIcon 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="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's ErrorTip icon (Font Awesome, 512x512), 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>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Friends 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 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.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Gear icon (Font Awesome, 512x512), 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>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-27.8 3.5-42.2 3.5s-28.4-1.2-42.2-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4l-55.7 17.7c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4c-1.1-8.3-1.7-16.8-1.7-25.4s.6-17.1 1.7-25.4l-43.3-39.4c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.8 1.2 241.8 0 256.2 0s28.4 1.2 42.2 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160a80 80 0 1 0 0 160z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's GithubIcon icon (Font Awesome, 496x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="8">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's QuestionTip icon (Font Awesome, 512x512), 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>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's SuccessTip icon (Font Awesome, 512x512), 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>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z" />
</group>
</vector>
@@ -227,23 +227,89 @@
android:text="@string/launcher_nav_leaderboard" />
</LinearLayout>
<org.wiicompiled.quest.launcher.TapRow
android:id="@+id/nav_friends"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_friends" />
<TextView
style="@style/Launcher.NavLabel"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/launcher_nav_friends" />
<!-- WheelWizard's BoxText: friends online out of all of them. -->
<TextView
android:id="@+id/nav_friends_count"
style="@style/Launcher.StateBox"
android:text="0/0" />
</org.wiicompiled.quest.launcher.TapRow>
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/launcher_version"
android:layout_width="wrap_content"
<!-- WheelWizard's sidebar bottom bar: the team's status, the info menu, Settings and the
version (SidebarFooter). -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_pill"
android:paddingStart="12dp"
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp"
android:textColor="@color/neutral_300"
android:textSize="13sp" />
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/sidebar_status"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_marginEnd="6dp"
android:importantForAccessibility="no"
android:padding="1dp"
android:visibility="gone" />
<ImageView
android:id="@+id/sidebar_info"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_marginEnd="6dp"
android:clickable="true"
android:contentDescription="@string/sidebar_info"
android:focusable="true"
android:padding="1dp"
android:src="@drawable/ic_info"
android:tint="@color/sidebar_icon" />
<ImageView
android:id="@+id/sidebar_settings"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_marginEnd="6dp"
android:clickable="true"
android:contentDescription="@string/launcher_nav_settings"
android:focusable="true"
android:padding="3dp"
android:src="@drawable/ic_gear"
android:tint="@color/sidebar_icon"
android:tooltipText="@string/launcher_nav_settings" />
<TextView
android:id="@+id/launcher_version"
android:layout_width="0dp"
android:layout_height="26dp"
android:layout_weight="1"
android:background="@drawable/bg_pill"
android:fontFamily="sans-serif-medium"
android:gravity="center_vertical|end"
android:paddingStart="6dp"
android:paddingEnd="8dp"
android:singleLine="true"
android:textColor="@color/neutral_300"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<FrameLayout
@@ -286,5 +352,10 @@
android:id="@+id/page_leaderboard"
layout="@layout/page_leaderboard"
android:visibility="gone" />
<include
android:id="@+id/page_friends"
layout="@layout/page_friends"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's AddFriendConfirmationWindow: the player about to be added, with their Mii turned
as the friend list shows it, name, friend code and VR. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="4dp"
android:paddingEnd="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="@string/friends_confirm_question"
android:textColor="@color/neutral_300"
android:textSize="20sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_mii_circle"
android:clipToOutline="true">
<ImageView
android:id="@+id/add_friend_placeholder"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_600" />
<ImageView
android:id="@+id/add_friend_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_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/add_friend_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_300"
android:textSize="18sp" />
<TextView
android:id="@+id/add_friend_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/neutral_300"
android:textSize="14sp" />
<TextView
android:id="@+id/add_friend_vr"
style="@style/Launcher.StateBox"
android:layout_marginTop="6dp"
android:background="@drawable/bg_state_box_success"
android:textColor="@color/primary_200" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Friends page's Add Friend (WheelWizard's TextInputWindow as FriendsPage sets it up): a
friend code, what is wrong with it or a warning, and the look-up on Retro WFC once submitted. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="4dp"
android:paddingEnd="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/friends_code_message"
android:textColor="@color/neutral_300"
android:textSize="15sp" />
<EditText
android:id="@+id/friend_code_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:digits="0123456789- "
android:hint="@string/friends_code_hint"
android:imeOptions="actionDone"
android:importantForAutofill="no"
android:inputType="number"
android:maxLength="16"
android:singleLine="true"
android:textColor="@color/neutral_100"
android:textColorHint="@color/neutral_500"
android:textSize="18sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="28dp"
android:orientation="horizontal">
<ProgressBar
android:id="@+id/friend_code_progress"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="8dp"
android:indeterminateTint="@color/neutral_500"
android:visibility="gone" />
<TextView
android:id="@+id/friend_code_note"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A friend (WheelWizard's FriendsListItem), laid out as the PC's 428 x 124 card: a grey strip
with the Mii turned sideways, fading at the bottom over Online, Offline or Pending; the name
and friend code, VR and BR; badges, View Room, wins and losses. An online friend's card has a
green outline and glow, a pending one's yellow; FriendsPage sets those. A TapFrame, so the
tips cannot swallow a tap, while View Room keeps its own. -->
<org.wiicompiled.quest.launcher.TapFrame xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="124dp"
android:background="@drawable/bg_friend_card"
android:clipToOutline="true">
<View
android:layout_width="78dp"
android:layout_height="match_parent"
android:background="@color/neutral_700" />
<View
android:id="@+id/friend_glow"
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_gravity="top|end"
android:layout_marginTop="-88dp"
android:layout_marginEnd="-90dp"
android:visibility="gone" />
<ImageView
android:id="@+id/friend_placeholder"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="17dp"
android:layout_marginTop="30dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_user"
android:tint="@color/neutral_500" />
<ImageView
android:id="@+id/friend_mii"
android:layout_width="138dp"
android:layout_height="138dp"
android:layout_marginStart="-34dp"
android:layout_marginTop="-19dp"
android:importantForAccessibility="no" />
<View
android:layout_width="248dp"
android:layout_height="match_parent"
android:background="@drawable/bg_friend_fade" />
<TextView
android:id="@+id/friend_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginBottom="5dp"
android:textSize="12sp" />
<LinearLayout
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginStart="93dp"
android:layout_marginTop="9dp"
android:orientation="vertical">
<TextView
android:id="@+id/friend_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="@color/neutral_300"
android:textSize="20sp" />
<TextView
android:id="@+id/friend_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="93dp"
android:layout_marginBottom="9dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="66dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/friend_vr_label"
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:drawableEnd="@drawable/ic_info"
android:drawablePadding="5dp"
android:drawableTint="@color/neutral_300"
android:text="@string/room_vr"
android:tooltipText="@string/room_vr_full" />
<TextView
android:id="@+id/friend_vr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/friend_br_label"
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:drawableEnd="@drawable/ic_info"
android:drawablePadding="5dp"
android:drawableTint="@color/neutral_300"
android:text="@string/friends_br"
android:tooltipText="@string/friends_br_full" />
<TextView
android:id="@+id/friend_br"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
<HorizontalScrollView
android:id="@+id/friend_badges_box"
android:layout_width="105dp"
android:layout_height="40dp"
android:layout_marginStart="258dp"
android:layout_marginTop="15dp"
android:padding="3dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/friend_badges"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal" />
</HorizontalScrollView>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="263dp"
android:layout_marginBottom="9dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="66dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/friends_wins" />
<TextView
android:id="@+id/friend_wins"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/friends_losses" />
<TextView
android:id="@+id/friend_losses"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/friend_view_room"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_gravity="top|end"
android:layout_marginTop="15dp"
android:layout_marginEnd="15dp"
android:background="@drawable/bg_play_button"
android:clickable="true"
android:drawableStart="@drawable/ic_road"
android:drawablePadding="10dp"
android:drawableTint="@color/play_button_content"
android:focusable="true"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/leaderboard_view_room"
android:textColor="@color/play_button_content"
android:textSize="14sp" />
</org.wiicompiled.quest.launcher.TapFrame>
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Friends page (Views/Pages/FriendsPage.axaml): the friends of the sidebar's
licence (item_friend.xml), sorted as chosen, with Add Friend, or the page's empty state. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/launcher_nav_friends" />
<ImageView
android:id="@+id/friends_add_top"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="10dp"
android:background="@drawable/bg_play_button"
android:clickable="true"
android:contentDescription="@string/friends_add"
android:focusable="true"
android:padding="8dp"
android:src="@drawable/ic_plus"
android:tint="@color/play_button_content"
android:tooltipText="@string/friends_add"
android:visibility="gone" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="12dp"
android:contentDescription="@string/friends_disclaimer"
android:src="@drawable/ic_info"
android:tint="@color/neutral_500"
android:tooltipText="@string/friends_disclaimer" />
</LinearLayout>
</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/friends_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="52dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/friends_list_title" />
<TextView
android:id="@+id/friends_count"
style="@style/Launcher.StateBox"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.FieldLabel"
android:layout_marginBottom="0dp"
android:text="@string/friends_sort_by" />
<Spinner
android:id="@+id/friends_sort"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp" />
</LinearLayout>
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ListView
android:id="@+id/friends_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:divider="@android:color/transparent"
android:dividerHeight="10dp"
android:listSelector="@android:color/transparent"
android:paddingTop="10dp"
android:paddingBottom="12dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/friends_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="150dp"
android:layout_height="150dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_wheel"
android:tint="@color/neutral_900" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="@string/friends_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:maxWidth="440dp"
android:text="@string/friends_empty_message"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
<TextView
android:id="@+id/friends_add_empty"
style="@style/Launcher.Button.Primary"
android:layout_marginTop="12dp"
android:drawableStart="@drawable/ic_plus"
android:drawablePadding="10dp"
android:drawableTint="@color/play_button_content"
android:minWidth="170dp"
android:paddingStart="20dp"
android:paddingEnd="24dp"
android:text="@string/friends_add_button"
android:tooltipText="@string/friends_add" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -27,6 +27,8 @@
<color name="warning_50">#FFFEE7</color>
<color name="warning_100">#FFFDC1</color>
<color name="warning_200">#FFF686</color>
<color name="warning_400">#FFD80D</color>
<color name="warning_500">#FFC800</color>
<color name="warning_600">#D19200</color>
<color name="warning_700">#A66802</color>
@@ -14,6 +14,20 @@
<string name="launcher_section_online">Online</string>
<string name="launcher_nav_rooms">Rooms</string>
<string name="launcher_nav_leaderboard">Leaderboard</string>
<string name="launcher_nav_friends">Friends</string>
<string name="launcher_friends_count">%1$d/%2$d</string>
<string name="sidebar_info">Info</string>
<string name="sidebar_status_failed">Failed to retrieve Wheel Wizard status</string>
<string name="sidebar_made_by">Quest port by iChris4\nWheel Wizard by Patchzy\nand WantToBeeMe</string>
<string name="sidebar_about">About</string>
<string name="sidebar_discord">Talk to us!</string>
<string name="sidebar_github">Source code</string>
<string name="sidebar_support">Support us!</string>
<string name="launcher_friends_online_none">There are currently no friends online</string>
<plurals name="launcher_friends_online">
<item quantity="one">There is currently %d friend online</item>
<item quantity="other">There are currently %d friends online</item>
</plurals>
<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>
@@ -203,6 +217,49 @@
<string name="leaderboard_unknown_player">Unknown Player</string>
<string name="leaderboard_view_room">View Room</string>
<string name="leaderboard_no_room">Could not find an active room for this player.</string>
<string name="friends_disclaimer">To add friends you need to add them in-game.</string>
<string name="friends_add">Add Friend</string>
<string name="friends_add_button">Add friend</string>
<string name="friends_list_title">Friends</string>
<string name="friends_sort_by">Sort by :</string>
<string name="friends_sort_online">Is Online</string>
<string name="friends_sort_name">Name</string>
<string name="friends_sort_races">Races played</string>
<string name="friends_empty_title">No friends yet!</string>
<string name="friends_empty_message">You can add friends in-game</string>
<string name="friends_online">Online</string>
<string name="friends_offline">Offline</string>
<string name="friends_pending">Pending</string>
<string name="friends_br">BR</string>
<string name="friends_br_full">Battle Rating</string>
<string name="friends_wins">Wins</string>
<string name="friends_losses">Losses</string>
<string name="friends_no_room">Whoops, could not find the room that this player is supposedly playing in.</string>
<string name="friends_code_message">Enter a 12-digit friend code.</string>
<string name="friends_code_hint">0000-0000-0000</string>
<string name="friends_submit">Submit</string>
<string name="friends_looking_up">Looking the friend code up on Retro WFC…</string>
<string name="friends_not_found">Could not find that friend code online.</string>
<string name="friends_profile_no_mii">This profile has no valid Mii data.</string>
<string name="friends_confirm_question">Is this who you want to add?</string>
<string name="friends_confirm_vr">VR %1$d</string>
<string name="friends_unknown_player">Unknown player</string>
<string name="friends_added">Added %1$s to your friend list.</string>
<string name="friends_remove">Remove Friend</string>
<string name="friends_remove_question">Remove %1$s (%2$s) from your friend list?</string>
<string name="friends_removed">Removed %1$s from your friend list.</string>
<string name="friends_list_full">Your friend list is full.</string>
<string name="friends_list_full_remove">Your friend list is full. Remove a friend and try again.</string>
<string name="friends_no_license">Select a valid license before adding friends.</string>
<string name="friends_code_empty">Friend code cannot be empty.</string>
<string name="friends_code_digits">Friend code must be exactly 12 digits.</string>
<string name="friends_code_invalid">Invalid friend code.</string>
<string name="friends_code_own">You cannot add your own friend code.</string>
<string name="friends_already">This friend is already in your list.</string>
<string name="friends_not_in_list">Friend was not found in this license.</string>
<string name="friends_no_save">Retro Rewind\'s save could not be read. Play it once with a licence first.</string>
<string name="friends_cannot_write">Retro Rewind\'s save could not be written.</string>
<string name="friends_close_game">Close the game first: it reads the friend list from its save.</string>
<plurals name="time_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
@@ -438,6 +495,8 @@
<string name="about_credit_title_mod_browser">Mod browser</string>
<string name="about_credit_title_profiles">My profiles</string>
<string name="about_credit_title_miis">My Miis</string>
<string name="about_credit_title_online">Online</string>
<string name="about_credit_online">Rooms, Leaderboard and Friends show Retro WFC\'s (rwfc.net) rooms, leaderboard and player profiles, and Friends reads and changes the friend list in Retro Rewind\'s save, as Wheel Wizard does. The status beside the version is the Wheel Wizard team\'s, from Wheel Wizard\'s data.</string>
<string name="about_credit_title_toolchain">On-headset builds</string>
<string name="about_credit_title_dolphin">Dolphin</string>
<string name="about_credit_title_logo">Logo</string>
@@ -0,0 +1,159 @@
package org.wiicompiled.quest.launcher
import java.util.zip.CRC32
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Friend lists are read and changed in rksys.dat as WheelWizard's GameLicenseService does. */
class RksysFriendsTest {
private val ownId = 0x01234567L
private val friendId = 0x00ABCDEFL
/** A whole save with one licence (slot 0) whose profile ID is [profileId]. */
private fun save(profileId: Long = ownId): ByteArray = ByteArray(RksysFriends.SAVE_SIZE).also { save ->
"RKSD0006".toByteArray(Charsets.US_ASCII).copyInto(save, 0)
"RKPD".toByteArray(Charsets.US_ASCII).copyInto(save, 8)
putU32(save, 8 + 0x5C, profileId)
}
private fun code(profileId: Long): String = RksysProfiles.friendCode(profileId)
private val mii = MiiFactory.female("Friend")
@Test
fun addingWritesThePcsPendingRequest() {
val save = save()
val data = MiiData.serialize(mii)
assertNull(RksysFriends.add(save, 0, code(friendId), data, vr = 6050))
val slot = 8 + 0x56D0
val registration = 8 + 0x8B50
assertEquals(code(friendId).replace("-", "").toLong() ushr 32, u32(save, slot))
assertEquals(friendId, u32(save, slot + 4))
assertEquals(1, u16(save, slot + 0x10))
assertEquals(listOf(0, 0, 6050, 5000), listOf(0x12, 0x14, 0x16, 0x18).map { u16(save, slot + it) })
assertArrayEquals(data, save.copyOfRange(slot + 0x1A, slot + 0x1A + MiiData.SIZE))
assertEquals(MiiDatabase.crc16(data, 0, data.size), u16(save, slot + 0x64))
assertEquals(listOf(0, 0, 0xFF, 0xFF), (0x66..0x69).map { save[slot + it].toInt() and 0xFF })
assertEquals(List(10) { 0xFFFFFFFFL }, (0 until 10).map { u32(save, slot + 0x70 + it * 8) })
assertEquals(0x10, save[registration + 2].toInt())
assertEquals(friendId, u32(save, registration + 4))
assertEquals(CRC32().apply { update(save, 0, 0x27FFC) }.value, u32(save, 0x27FFC))
val friend = RksysFriends.parse(save, 0).single()
assertEquals(code(friendId), friend.friendCode)
assertEquals(mii, friend.mii)
assertEquals("Friend", friend.name)
assertEquals(6050, friend.vr)
assertEquals(5000, friend.br)
assertTrue(friend.isPending)
}
@Test
fun aFriendWhoAddedBackIsNoLongerPending() {
val save = save()
RksysFriends.add(save, 0, code(friendId), MiiData.serialize(mii), vr = 5000)
// The Wii rewrites the registration once the friend adds this licence back.
save[8 + 0x8B50 + 2] = 0x38
assertFalse(RksysFriends.parse(save, 0).single().isPending)
// Old one-sided entries kept 0 there, and are pending too.
save[8 + 0x8B50 + 2] = 0
assertTrue(RksysFriends.parse(save, 0).single().isPending)
// Both state bits: mutual.
putU16(save, 8 + 0x56D0 + 0x10, 3)
assertFalse(RksysFriends.parse(save, 0).single().isPending)
}
@Test
fun addingRefusesWhatThePcRefuses() {
val data = MiiData.serialize(mii)
val wrongChecksum = code(friendId).let { valid -> (if (valid[0] == '9') "0" else "9") + valid.substring(1) }
assertEquals(RksysFriends.Problem.Invalid, RksysFriends.add(save(), 0, wrongChecksum, data, 5000))
assertEquals(RksysFriends.Problem.Own, RksysFriends.add(save(), 0, code(ownId), data, 5000))
assertEquals(RksysFriends.Problem.NoLicense, RksysFriends.add(save(profileId = 0), 0, code(friendId), data, 5000))
assertEquals(RksysFriends.Problem.NoLicense, RksysFriends.add(save(), 1, code(friendId), data, 5000))
assertEquals(RksysFriends.Problem.NotASave, RksysFriends.add(ByteArray(0x30000), 0, code(friendId), data, 5000))
val save = save()
assertNull(RksysFriends.add(save, 0, code(friendId), data, 5000))
assertEquals(RksysFriends.Problem.Duplicate, RksysFriends.add(save, 0, code(friendId), data, 5000))
(1 until RksysFriends.MAX).forEach { assertNull(RksysFriends.add(save, 0, code(friendId + it), data, 5000)) }
assertEquals(RksysFriends.MAX, RksysFriends.parse(save, 0).size)
assertEquals(RksysFriends.Problem.Full, RksysFriends.add(save, 0, code(friendId + 100), data, 5000))
}
@Test
fun removingEmptiesTheSlotForTheNextFriend() {
val save = save()
val data = MiiData.serialize(mii)
RksysFriends.add(save, 0, code(friendId), data, 5000)
RksysFriends.add(save, 0, code(friendId + 1), data, 5000)
assertTrue(RksysFriends.remove(save, 0, friendId))
assertFalse(RksysFriends.remove(save, 0, friendId))
assertTrue((8 + 0x56D0 until 8 + 0x56D0 + 0x1C0).all { save[it] == 0.toByte() })
assertTrue((8 + 0x8B50 until 8 + 0x8B50 + 0x0C).all { save[it] == 0.toByte() })
assertEquals(CRC32().apply { update(save, 0, 0x27FFC) }.value, u32(save, 0x27FFC))
assertEquals(listOf(1), RksysFriends.parse(save, 0).map { it.slot })
// The first empty slot takes the next friend.
RksysFriends.add(save, 0, code(friendId + 2), data, 5000)
assertEquals(listOf(0 to friendId + 2, 1 to friendId + 1), RksysFriends.parse(save, 0).map { it.slot to it.profileId })
}
@Test
fun friendCodesAreCheckedAsThePcChecksThem() {
val valid = code(friendId)
assertEquals(friendId, RksysFriends.profileId(valid))
assertEquals(valid, RksysFriends.normalize(" ${valid.replace("-", " ")} ").friendCode)
assertEquals(RksysFriends.Problem.Empty, RksysFriends.normalize(" ").problem)
assertEquals(RksysFriends.Problem.NotTwelveDigits, RksysFriends.normalize("1234-5678").problem)
assertEquals(RksysFriends.Problem.Invalid, RksysFriends.normalize("0000-0000-0000").problem)
assertEquals(0L, RksysFriends.profileId("9999-9999-9999"))
}
@Test
fun friendsAreSortedAsThePageChooses() {
fun friend(slot: Int, name: String, vr: Int, wins: Int, losses: Int) =
RksysFriends.Friend(slot, slot.toLong(), "", MiiFactory.male(name), vr, 5000, wins, losses, 0xFF, 0xFF, isPending = false)
val friends = listOf(friend(0, "bob", 100, 5, 0), friend(1, "Anna", 300, 1, 9), friend(2, "carl", 200, 5, 1))
val names = { order: FriendList.Order, online: (RksysFriends.Friend) -> Boolean ->
FriendList.sorted(friends, order, online).map { it.name }
}
assertEquals(listOf("carl", "bob", "Anna"), names(FriendList.Order.Online) { it.slot == 2 })
assertEquals(listOf("Anna", "carl", "bob"), names(FriendList.Order.Vr) { false })
assertEquals(listOf("Anna", "bob", "carl"), names(FriendList.Order.Name) { false })
// Equal wins keep the save's order.
assertEquals(listOf("bob", "carl", "Anna"), names(FriendList.Order.Wins) { false })
assertEquals(listOf("Anna", "carl", "bob"), names(FriendList.Order.Races) { false })
}
@Test
fun aNulInsideAMiiNameIsNotShown() {
val save = save()
val named = MiiFactory.male("matt").apply { name = "matt\u0000ias" }
RksysFriends.add(save, 0, code(friendId), MiiData.serialize(named), 5000)
assertEquals("mattias", RksysFriends.parse(save, 0).single().name)
}
private fun u16(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
private fun u32(data: ByteArray, offset: Int): Long =
((data[offset].toLong() and 0xFF) shl 24) or ((data[offset + 1].toLong() and 0xFF) shl 16) or
((data[offset + 2].toLong() and 0xFF) shl 8) or (data[offset + 3].toLong() and 0xFF)
private fun putU16(data: ByteArray, offset: Int, value: Int) {
data[offset] = (value ushr 8).toByte()
data[offset + 1] = value.toByte()
}
private fun putU32(data: ByteArray, offset: Int, value: Long) {
data[offset] = (value ushr 24).toByte()
data[offset + 1] = (value ushr 16).toByte()
data[offset + 2] = (value ushr 8).toByte()
data[offset + 3] = value.toByte()
}
}
@@ -0,0 +1,100 @@
package org.wiicompiled.quest.launcher
import java.io.IOException
import kotlin.math.abs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/** Wheel Wizard's status is read as the PC reads status.json, and its own icon as Avalonia parses path data. */
class SidebarStatusTest {
@Test
fun theStatusIsReadAsThePcReadsIt() {
// status.json as the Wheel Wizard team publishes it.
val live = WheelWizardStatus.parse(
"""{"message" : "Unstable servers!", "variant": "warning",
"we_dont_actually_parse_this_key___all_posible_variants_here" : ["none", "warning", "error"]}""",
)
assertEquals(WheelWizardStatus.Variant.Warning, live.variant)
assertEquals("Unstable servers!", live.message)
assertTrue(live.visible)
assertFalse(WheelWizardStatus.parse("""{"message": "All good", "variant": "None"}""").visible)
val custom = WheelWizardStatus.parse("""{"message": "Party!", "icon": "M0 0L10 10Z", "color": "#123456"}""")
assertNull(custom.variant)
assertTrue(custom.visible)
assertEquals("#123456", custom.color)
// No message, or a variant the PC does not know, is no status at all.
for (json in listOf("""{"variant": "warning"}""", """{"message": "x", "variant": "panic"}""", "not json")) {
try {
WheelWizardStatus.parse(json)
fail("Read $json")
} catch (e: IOException) {
// As expected.
}
}
}
@Test
fun pathDataIsReadWithEveryCommand() {
val segments = SvgPath.parse("M10 20h5v-5l5,5L30 30c1 1 2 2 3 3s4 4 5 5Q40 40 50 50t10 10z m1 1")
assertEquals(SvgPath.Segment.Move(10f, 20f), segments[0])
assertEquals(SvgPath.Segment.Line(15f, 20f), segments[1])
assertEquals(SvgPath.Segment.Line(15f, 15f), segments[2])
assertEquals(SvgPath.Segment.Line(20f, 20f), segments[3])
assertEquals(SvgPath.Segment.Line(30f, 30f), segments[4])
assertEquals(SvgPath.Segment.Cubic(31f, 31f, 32f, 32f, 33f, 33f), segments[5])
// S mirrors the last control point about the current point.
assertEquals(SvgPath.Segment.Cubic(34f, 34f, 37f, 37f, 38f, 38f), segments[6])
assertEquals(SvgPath.Segment.Quad(40f, 40f, 50f, 50f), segments[7])
assertEquals(SvgPath.Segment.Quad(60f, 60f, 60f, 60f), segments[8])
assertEquals(SvgPath.Segment.Close, segments[9])
// After Z the pen is back at the start of the figure.
assertEquals(SvgPath.Segment.Move(11f, 21f), segments[10])
}
@Test
fun numbersAndArcFlagsMayRunTogether() {
// As Font Awesome writes them: ".5.5" is two numbers, "-1-2" too, and flags need no spaces.
val segments = SvgPath.parse("M.5.5L-1-2a10 10 0 0110 10")
assertEquals(SvgPath.Segment.Move(0.5f, 0.5f), segments[0])
assertEquals(SvgPath.Segment.Line(-1f, -2f), segments[1])
val end = segments.last() as SvgPath.Segment.Cubic
assertEquals(9f, end.x, 1e-3f)
assertEquals(8f, end.y, 1e-3f)
assertEquals(1e3f, (SvgPath.parse("M1e3 0")[0] as SvgPath.Segment.Move).x)
}
@Test
fun arcsBecomeCurvesAlongTheCircle() {
// A half circle of radius 10 from (0, 0) to (20, 0), passing below through (10, 10).
val curves = SvgPath.parse("M0 0A10 10 0 0 0 20 0").drop(1).map { it as SvgPath.Segment.Cubic }
assertEquals(2, curves.size)
assertEquals(10f, curves[0].x, 1e-3f)
assertEquals(10f, curves[0].y, 1e-3f)
assertEquals(20f, curves[1].x, 1e-3f)
assertEquals(0f, curves[1].y, 1e-3f)
// Control points of a quarter circle sit 0.5523 of the radius along the tangents.
assertTrue(abs(curves[0].y1 - 5.523f) < 1e-2f)
// A radius too small is grown to reach the end.
val grown = SvgPath.parse("M0 0A1 1 0 0 1 20 0").last() as SvgPath.Segment.Cubic
assertEquals(20f, grown.x, 1e-3f)
}
@Test
fun badPathDataIsRefused() {
for (data in listOf("10 10", "M0 0 A5 5 0 2 0 5 5", "M0 0 X1", "M0")) {
try {
SvgPath.parse(data)
fail("Read $data")
} catch (e: IllegalArgumentException) {
// As expected.
}
}
}
}