From 13311ea626d07a691c16ca4c6763bde06011ca95 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 24 Sep 2026 17:04:18 +0200 Subject: [PATCH] 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. --- .../quest/launcher/FriendActions.kt | 246 +++++++++++++++ .../wiicompiled/quest/launcher/FriendList.kt | 131 ++++++++ .../wiicompiled/quest/launcher/FriendsPage.kt | 254 ++++++++++++++++ .../quest/launcher/LauncherActivity.kt | 66 +++- .../quest/launcher/LeaderboardPage.kt | 19 +- .../wiicompiled/quest/launcher/MiiImages.kt | 11 +- .../wiicompiled/quest/launcher/MiiRenderer.kt | 3 +- .../quest/launcher/PlayerActions.kt | 20 +- .../quest/launcher/ProfileStore.kt | 2 +- .../quest/launcher/RksysFriends.kt | 216 +++++++++++++ .../quest/launcher/RksysProfiles.kt | 7 +- .../wiicompiled/quest/launcher/RoomsPage.kt | 11 +- .../quest/launcher/SettingsPage.kt | 1 + .../quest/launcher/SidebarFooter.kt | 163 ++++++++++ .../org/wiicompiled/quest/launcher/SvgPath.kt | 283 ++++++++++++++++++ .../org/wiicompiled/quest/launcher/TapRow.kt | 20 +- .../quest/launcher/WheelWizardStatus.kt | 126 ++++++++ .../app/src/main/res/color/sidebar_icon.xml | 7 + .../src/main/res/drawable/bg_friend_card.xml | 6 + .../src/main/res/drawable/bg_friend_fade.xml | 11 + .../src/main/res/drawable/bg_menu_divider.xml | 11 + .../app/src/main/res/drawable/ic_coffee.xml | 13 + .../app/src/main/res/drawable/ic_discord.xml | 13 + .../src/main/res/drawable/ic_error_tip.xml | 13 + .../app/src/main/res/drawable/ic_friends.xml | 13 + android/app/src/main/res/drawable/ic_gear.xml | 13 + .../app/src/main/res/drawable/ic_github.xml | 13 + .../src/main/res/drawable/ic_question_tip.xml | 13 + .../src/main/res/drawable/ic_success_tip.xml | 13 + .../src/main/res/layout/activity_launcher.xml | 93 +++++- .../src/main/res/layout/dialog_add_friend.xml | 80 +++++ .../main/res/layout/dialog_friend_code.xml | 57 ++++ .../app/src/main/res/layout/item_friend.xml | 220 ++++++++++++++ .../app/src/main/res/layout/page_friends.xml | 182 +++++++++++ android/app/src/main/res/values/colors.xml | 2 + android/app/src/main/res/values/strings.xml | 59 ++++ .../quest/launcher/RksysFriendsTest.kt | 159 ++++++++++ .../quest/launcher/SidebarStatusTest.kt | 100 +++++++ docs/quest-port.md | 50 +++- 39 files changed, 2670 insertions(+), 50 deletions(-) create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/FriendActions.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/FriendList.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/FriendsPage.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/RksysFriends.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarFooter.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/SvgPath.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/launcher/WheelWizardStatus.kt create mode 100644 android/app/src/main/res/color/sidebar_icon.xml create mode 100644 android/app/src/main/res/drawable/bg_friend_card.xml create mode 100644 android/app/src/main/res/drawable/bg_friend_fade.xml create mode 100644 android/app/src/main/res/drawable/bg_menu_divider.xml create mode 100644 android/app/src/main/res/drawable/ic_coffee.xml create mode 100644 android/app/src/main/res/drawable/ic_discord.xml create mode 100644 android/app/src/main/res/drawable/ic_error_tip.xml create mode 100644 android/app/src/main/res/drawable/ic_friends.xml create mode 100644 android/app/src/main/res/drawable/ic_gear.xml create mode 100644 android/app/src/main/res/drawable/ic_github.xml create mode 100644 android/app/src/main/res/drawable/ic_question_tip.xml create mode 100644 android/app/src/main/res/drawable/ic_success_tip.xml create mode 100644 android/app/src/main/res/layout/dialog_add_friend.xml create mode 100644 android/app/src/main/res/layout/dialog_friend_code.xml create mode 100644 android/app/src/main/res/layout/item_friend.xml create mode 100644 android/app/src/main/res/layout/page_friends.xml create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/RksysFriendsTest.kt create mode 100644 android/app/src/test/java/org/wiicompiled/quest/launcher/SidebarStatusTest.kt diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendActions.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendActions.kt new file mode 100644 index 0000000..390c9eb --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendActions.kt @@ -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(R.id.add_friend_name).text = shownName + view.findViewById(R.id.add_friend_code).text = friendCode + view.findViewById(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(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()) } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendList.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendList.kt new file mode 100644 index 0000000..99f153b --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendList.kt @@ -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 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, order: Order, online: (RksysFriends.Friend) -> Boolean): List = + 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 + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendsPage.kt new file mode 100644 index 0000000..ac4691f --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/FriendsPage.kt @@ -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> = 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(R.id.friends_add_empty).setOnClickListener { actions.addByCode() } + root.findViewById(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 = 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(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(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(R.id.friend_name).text = friend.name + card.findViewById(R.id.friend_code).text = friend.friendCode + card.findViewById(R.id.friend_vr).text = rating(friend.vr) + card.findViewById(R.id.friend_br).text = rating(friend.br) + card.findViewById(R.id.friend_wins).text = friend.wins.toString() + card.findViewById(R.id.friend_losses).text = friend.losses.toString() + + val badgeList = badges[friend.friendCode].orEmpty() + card.findViewById(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(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(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(R.id.friend_mii) + val placeholder = card.findViewById(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(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 + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 5024f56..f008c09 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -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 } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt index 2e3ec6f..7a7b6ab 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LeaderboardPage.kt @@ -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(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 } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt index 80fcba3..cec0848 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiImages.kt @@ -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) } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt index 41608d2..e7afc6a 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/MiiRenderer.kt @@ -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( diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt index 335e446..30ce3f8 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PlayerActions.kt @@ -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 diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt index 8af76ba..e83e958 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ProfileStore.kt @@ -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 diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysFriends.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysFriends.kt new file mode 100644 index 0000000..c012885 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysFriends.kt @@ -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 { + 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() + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt index 215bcec..9860ab9 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RksysProfiles.kt @@ -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 = 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), ) } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt index e61aecf..3b2bb0e 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RoomsPage.kt @@ -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 = "" diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index c860ab2..3da7ed8 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -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) } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarFooter.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarFooter.kt new file mode 100644 index 0000000..6075df3 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SidebarFooter.kt @@ -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(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) : 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" + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SvgPath.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SvgPath.kt new file mode 100644 index 0000000..6cbbb62 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SvgPath.kt @@ -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 { + val reader = Reader(data.trim().removePrefix("F0").removePrefix("F1")) + val out = ArrayList() + 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, + 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): 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' + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt index f97f7dd..7c1af8d 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/TapRow.kt @@ -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 diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelWizardStatus.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelWizardStatus.kt new file mode 100644 index 0000000..83535a0 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelWizardStatus.kt @@ -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() + } + } +} diff --git a/android/app/src/main/res/color/sidebar_icon.xml b/android/app/src/main/res/color/sidebar_icon.xml new file mode 100644 index 0000000..c49e631 --- /dev/null +++ b/android/app/src/main/res/color/sidebar_icon.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_friend_card.xml b/android/app/src/main/res/drawable/bg_friend_card.xml new file mode 100644 index 0000000..8d89d78 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_friend_card.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_friend_fade.xml b/android/app/src/main/res/drawable/bg_friend_fade.xml new file mode 100644 index 0000000..41f2c99 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_friend_fade.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_menu_divider.xml b/android/app/src/main/res/drawable/bg_menu_divider.xml new file mode 100644 index 0000000..5eb2179 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_menu_divider.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_coffee.xml b/android/app/src/main/res/drawable/ic_coffee.xml new file mode 100644 index 0000000..e26bd84 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_coffee.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_discord.xml b/android/app/src/main/res/drawable/ic_discord.xml new file mode 100644 index 0000000..4ffabb2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_error_tip.xml b/android/app/src/main/res/drawable/ic_error_tip.xml new file mode 100644 index 0000000..aad0dbd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_error_tip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_friends.xml b/android/app/src/main/res/drawable/ic_friends.xml new file mode 100644 index 0000000..3ea2838 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_friends.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_gear.xml b/android/app/src/main/res/drawable/ic_gear.xml new file mode 100644 index 0000000..0097fb1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_gear.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_github.xml b/android/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..3442da3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_question_tip.xml b/android/app/src/main/res/drawable/ic_question_tip.xml new file mode 100644 index 0000000..afdfbaa --- /dev/null +++ b/android/app/src/main/res/drawable/ic_question_tip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_success_tip.xml b/android/app/src/main/res/drawable/ic_success_tip.xml new file mode 100644 index 0000000..ecce94a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_success_tip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 5d9d15e..529fce1 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -227,23 +227,89 @@ android:text="@string/launcher_nav_leaderboard" /> + + + + + + + + + + - + + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_add_friend.xml b/android/app/src/main/res/layout/dialog_add_friend.xml new file mode 100644 index 0000000..855baaf --- /dev/null +++ b/android/app/src/main/res/layout/dialog_add_friend.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_friend_code.xml b/android/app/src/main/res/layout/dialog_friend_code.xml new file mode 100644 index 0000000..8820379 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_friend_code.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_friend.xml b/android/app/src/main/res/layout/item_friend.xml new file mode 100644 index 0000000..8c9bdb3 --- /dev/null +++ b/android/app/src/main/res/layout/item_friend.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/page_friends.xml b/android/app/src/main/res/layout/page_friends.xml new file mode 100644 index 0000000..bb469eb --- /dev/null +++ b/android/app/src/main/res/layout/page_friends.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index a4b263e..eed462c 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -27,6 +27,8 @@ #FFFEE7 #FFFDC1 + #FFF686 + #FFD80D #FFC800 #D19200 #A66802 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b189e9c..1223f1a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -14,6 +14,20 @@ Online Rooms Leaderboard + Friends + %1$d/%2$d + Info + Failed to retrieve Wheel Wizard status + Quest port by iChris4\nWheel Wizard by Patchzy\nand WantToBeeMe + About + Talk to us! + Source code + Support us! + There are currently no friends online + + There is currently %d friend online + There are currently %d friends online + There are currently no players online There is currently %d player online @@ -203,6 +217,49 @@ Unknown Player View Room Could not find an active room for this player. + To add friends you need to add them in-game. + Add Friend + Add friend + Friends + Sort by : + Is Online + Name + Races played + No friends yet! + You can add friends in-game + Online + Offline + Pending + BR + Battle Rating + Wins + Losses + Whoops, could not find the room that this player is supposedly playing in. + Enter a 12-digit friend code. + 0000-0000-0000 + Submit + Looking the friend code up on Retro WFC… + Could not find that friend code online. + This profile has no valid Mii data. + Is this who you want to add? + VR %1$d + Unknown player + Added %1$s to your friend list. + Remove Friend + Remove %1$s (%2$s) from your friend list? + Removed %1$s from your friend list. + Your friend list is full. + Your friend list is full. Remove a friend and try again. + Select a valid license before adding friends. + Friend code cannot be empty. + Friend code must be exactly 12 digits. + Invalid friend code. + You cannot add your own friend code. + This friend is already in your list. + Friend was not found in this license. + Retro Rewind\'s save could not be read. Play it once with a licence first. + Retro Rewind\'s save could not be written. + Close the game first: it reads the friend list from its save. %d day %d days @@ -438,6 +495,8 @@ Mod browser My profiles My Miis + 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. On-headset builds Dolphin Logo diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysFriendsTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysFriendsTest.kt new file mode 100644 index 0000000..e865da7 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RksysFriendsTest.kt @@ -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() + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/SidebarStatusTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/SidebarStatusTest.kt new file mode 100644 index 0000000..54457b5 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/SidebarStatusTest.kt @@ -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. + } + } + } +} diff --git a/docs/quest-port.md b/docs/quest-port.md index 566115d..5c7c62b 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -297,13 +297,13 @@ older fields. The page lists rooms with their game mode (the PC's `rk` names), I time online and player count, or with a search the players whose name or friend code holds it; a room opens its details (ID, time online, mode, average VR, and its players with their Mii, VR, badges, place and open host), and a player there Copy -Friend Code, View Mii and View Profile (`PlayerActions`, shared with the -Leaderboard). View Mii is the PC's MiiCarouselWindow: the +Friend Code, View Mii, Add Friend and View Profile (`PlayerActions`, shared with the +Leaderboard and Friends). View Mii is the PC's MiiCarouselWindow: the whole Mii in the renderer's `all_body` view, turned by dragging and zoomed with the thumbstick, small while it moves and sharp once it rests. View Profile is the PC's PlayerProfileWindow, with the VR history of My profiles (`VrHistoryPanel`, one -`view_vr_history` layout for both). The PC's Add Friend waits for the Friends page, -which reads and writes the save's friend list. Rows are `TapRow`s, which take every +`view_vr_history` layout for both). Add Friend is the Friends page's (below). Rows +are `TapRow`s, which take every tap so a tooltip or the badge strip inside cannot swallow one, while the pointer's hover still shows their tips; only a button inside a row keeps the taps that begin on it. `LiveRoomsTest` and `RetroWfcTest` cover it. @@ -328,6 +328,48 @@ digits) and follows the rooms as they change. Loading, the error with Retry and empty board with Refresh are the PC's too. `LeaderboardTest` and `RetroWfcTest` cover it. +**Friends**, after Leaderboard, is the PC's FriendsPage (`FriendsPage`), and the +only page besides My Miis that changes the game's files: the friend list of the +licence the sidebar shows (the primary one, else the first) in Retro Rewind's save. +`RksysFriends` reads and changes it as the PC's GameLicenseService does: 30 slots of +0x1C0 bytes at 0x56D0 in each RKPD block, and 12 bytes each at 0x8B50 for the Wii's +friend registration; a friend is pending while the slot's state is 1 and that +registration's control byte 0x10 (or 0). Add Friend writes the PC's one-sided request +(the friend code's upper half and the profile ID as its key, state 1, the Mii and its +CRC-16/XMODEM, VR clamped to 65535, BR 5000, country and region 0xFF, the ten records +0xFFFFFFFF, control 0x10) into the first empty slot, and Remove Friend empties the +slot; both write the save's CRC-32 over its first 0x27FFC bytes again. `FriendList` +reads the whole save, changes it in memory and replaces the file with a finished, +synced copy, then reads the list again; the save is also read each time the +launcher comes back, as the game may have changed it, and nothing is changed while +the game runs (`FriendActions`). The cards are the PC's FriendsListItem at its sizes: +the Mii turned sideways (FriendsSideProfile; FriendsSideProfilePending's angry face, +FFL expression 2, for a pending friend) in a grey strip fading over Online, Offline +or Pending, name and friend code, VR and BR (9999 shown as 9999+), badges, wins and +losses, and View Room, enabled while the friend is in a room, which opens it on the +Rooms page; an online friend's card has the green outline and glow, a pending one's +the yellow. A NUL inside a Mii's name is left out when shown, as Avalonia draws it. +The list sorts as the PC's (Is Online, VR, BR, Name, Wins, Races played, for the +session), except that names go from A to Z rather than Z to A. Add Friend on the page +asks for a friend code with the PC's checks (12 digits, a valid checksum, not your +own, a warning for a friend already listed), looks it up on Retro WFC for the name +and Mii, and confirms with the PC's AddFriendConfirmationWindow; Add Friend from a +room or the leaderboard uses the Mii and VR shown there. Unlike the PC, Remove Friend +asks first. The sidebar shows friends online out of all of them. `RksysFriendsTest` +covers the save, on a made-up save; on the headset an add then a remove left the +save byte for byte as it was. + +The sidebar ends as the PC's does (`SidebarFooter`): the Wheel Wizard team's status, +the info menu, Settings and the version. The status is WheelWizard-Data's +`status.json` (`WheelWizardStatus`, the PC's WhWzStatusManager), asked every 90 s +while the launcher is on screen and shown as an icon whose tip is its message: the +PC's preset icon and colour for each variant, or an icon of its own as SVG path data +in its colour (`SvgPath`, every path command, arcs as cubic curves), nothing for +None, and the PC's red error when the status cannot be read. The info menu names who +made the Quest port and Wheel Wizard, opens Settings on About, and links to Wheel +Wizard's Discord, this repository on GitHub and Wheel Wizard's Ko-fi. +`SidebarStatusTest` covers the status and the path data. + The launcher follows the runtime's rules exactly. `TomlConfig` edits one line the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file, so values the in-headset panel wrote are kept. Each row reads its key with the