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