mirror of
https://github.com/skyline-emu/skyline.git
synced 2024-12-23 19:21:55 +01:00
Refactor and Convert Adapters to RecyclerView.Adapter
This commit mainly refactors the adapters by adding spacing, comments and following other guidelines. In addition, it moves from using `BaseAdapter` to `RecyclerView.Adapter` which leads to much cleaner adapter classes. a
This commit is contained in:
parent
55a9f8e937
commit
d86d5c1a35
@ -15,7 +15,7 @@ import android.view.SurfaceHolder
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import emu.skyline.loader.getRomFormat
|
import emu.skyline.loader.getRomFormat
|
||||||
import kotlinx.android.synthetic.main.game_activity.*
|
import kotlinx.android.synthetic.main.app_activity.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -15,7 +15,11 @@ import android.widget.Toast
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import emu.skyline.adapter.LogAdapter
|
import emu.skyline.adapter.LogAdapter
|
||||||
|
import kotlinx.android.synthetic.main.log_activity.*
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
@ -32,10 +36,19 @@ class LogActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.log_activity)
|
setContentView(R.layout.log_activity)
|
||||||
setSupportActionBar(findViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
val logList = findViewById<ListView>(R.id.log_list)
|
val compact = prefs.getBoolean("log_compact", false)
|
||||||
adapter = LogAdapter(this, prefs.getBoolean("log_compact", false), prefs.getString("log_level", "3")!!.toInt(), resources.getStringArray(R.array.log_level))
|
val logLevel = prefs.getString("log_level", "3")!!.toInt()
|
||||||
logList.adapter = adapter
|
|
||||||
|
adapter = LogAdapter(this, compact, logLevel, resources.getStringArray(R.array.log_level))
|
||||||
|
|
||||||
|
log_list.adapter = adapter
|
||||||
|
log_list.layoutManager = LinearLayoutManager(this)
|
||||||
|
|
||||||
|
if (!compact)
|
||||||
|
log_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logFile = File("${applicationInfo.dataDir}/skyline.log")
|
logFile = File("${applicationInfo.dataDir}/skyline.log")
|
||||||
logFile.forEachLine {
|
logFile.forEachLine {
|
||||||
|
@ -18,6 +18,9 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import emu.skyline.adapter.AppAdapter
|
import emu.skyline.adapter.AppAdapter
|
||||||
import emu.skyline.adapter.AppItem
|
import emu.skyline.adapter.AppItem
|
||||||
@ -128,12 +131,12 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
|
|||||||
setSupportActionBar(toolbar)
|
setSupportActionBar(toolbar)
|
||||||
open_fab.setOnClickListener(this)
|
open_fab.setOnClickListener(this)
|
||||||
log_fab.setOnClickListener(this)
|
log_fab.setOnClickListener(this)
|
||||||
game_list.adapter = adapter
|
adapter = AppAdapter(this)
|
||||||
game_list.onItemClickListener = OnItemClickListener { parent: AdapterView<*>, _: View?, position: Int, _: Long ->
|
app_list.adapter = adapter
|
||||||
val item = parent.getItemAtPosition(position)
|
|
||||||
if (item is AppItem) {
|
app_list.layoutManager = LinearLayoutManager(this)
|
||||||
val intent = Intent(this, EmulationActivity::class.java)
|
app_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
|
||||||
intent.data = item.uri
|
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,8 +184,34 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
|
|||||||
intent.type = "*/*"
|
intent.type = "*/*"
|
||||||
startActivityForResult(intent, 2)
|
startActivityForResult(intent, 2)
|
||||||
}
|
}
|
||||||
|
R.id.app_item_linear -> {
|
||||||
|
val tag = view.tag
|
||||||
|
|
||||||
|
if (tag is AppItem) {
|
||||||
|
val intent = Intent(this, EmulationActivity::class.java)
|
||||||
|
intent.data = tag.uri
|
||||||
|
|
||||||
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(view: View?): Boolean {
|
||||||
|
when (view?.id) {
|
||||||
|
R.id.app_item_linear -> {
|
||||||
|
val tag = view.tag
|
||||||
|
|
||||||
|
if (tag is AppItem) {
|
||||||
|
val dialog = AppDialog(tag)
|
||||||
|
dialog.show(supportFragmentManager, "game")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
|
@ -19,7 +19,10 @@ import android.widget.ImageView
|
|||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import emu.skyline.R
|
import emu.skyline.R
|
||||||
|
import emu.skyline.adapter.ElementType.Header
|
||||||
|
import emu.skyline.adapter.ElementType.Item
|
||||||
import emu.skyline.loader.AppEntry
|
import emu.skyline.loader.AppEntry
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,7 +39,7 @@ class AppItem(val meta: AppEntry) : BaseItem() {
|
|||||||
* The title of the application
|
* The title of the application
|
||||||
*/
|
*/
|
||||||
val title: String
|
val title: String
|
||||||
get() = meta.name + " (" + type + ")"
|
get() = meta.name
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The string used as the sub-title, we currently use the author
|
* The string used as the sub-title, we currently use the author
|
||||||
@ -56,6 +59,9 @@ class AppItem(val meta: AppEntry) : BaseItem() {
|
|||||||
private val type: String
|
private val type: String
|
||||||
get() = meta.format.name
|
get() = meta.format.name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name and author is used as the key
|
||||||
|
*/
|
||||||
override fun key(): String? {
|
override fun key(): String? {
|
||||||
return if (meta.author != null) meta.name + " " + meta.author else meta.name
|
return if (meta.author != null) meta.name + " " + meta.author else meta.name
|
||||||
}
|
}
|
||||||
@ -64,29 +70,41 @@ class AppItem(val meta: AppEntry) : BaseItem() {
|
|||||||
/**
|
/**
|
||||||
* This adapter is used to display all found applications using their metadata
|
* This adapter is used to display all found applications using their metadata
|
||||||
*/
|
*/
|
||||||
internal class AppAdapter(val context: Context?) : HeaderAdapter<AppItem, BaseHeader>(), View.OnClickListener {
|
internal class AppAdapter(val context: Context?) : HeaderAdapter<AppItem, BaseHeader, RecyclerView.ViewHolder>(), View.OnClickListener {
|
||||||
/**
|
/**
|
||||||
* This adds a string header to the view
|
* The icon to use on items that don't have a valid icon
|
||||||
|
*/
|
||||||
|
private val missingIcon = context?.resources?.getDrawable(R.drawable.default_icon, context.theme)?.toBitmap(256, 256)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The string to use as a description for items that don't have a valid description
|
||||||
|
*/
|
||||||
|
private val missingString = context?.getString(R.string.metadata_missing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This adds a header to the view with the contents of [string]
|
||||||
*/
|
*/
|
||||||
fun addHeader(string: String) {
|
fun addHeader(string: String) {
|
||||||
super.addHeader(BaseHeader(string))
|
super.addHeader(BaseHeader(string))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The onClick handler, it's for displaying the icon preview
|
* The onClick handler for the supplied [view], used for the icon preview
|
||||||
*
|
|
||||||
* @param view The specific view that was clicked
|
|
||||||
*/
|
*/
|
||||||
override fun onClick(view: View) {
|
override fun onClick(view: View) {
|
||||||
val position = view.tag as Int
|
val position = view.tag as Int
|
||||||
|
|
||||||
if (getItem(position) is AppItem) {
|
if (getItem(position) is AppItem) {
|
||||||
val item = getItem(position) as AppItem
|
val item = getItem(position) as AppItem
|
||||||
|
|
||||||
if (view.id == R.id.icon) {
|
if (view.id == R.id.icon) {
|
||||||
val builder = Dialog(context!!)
|
val builder = Dialog(context!!)
|
||||||
builder.requestWindowFeature(Window.FEATURE_NO_TITLE)
|
builder.requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||||
builder.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
builder.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||||
|
|
||||||
val imageView = ImageView(context)
|
val imageView = ImageView(context)
|
||||||
imageView.setImageBitmap(item.icon)
|
imageView.setImageBitmap(item.icon ?: missingIcon)
|
||||||
|
|
||||||
builder.addContentView(imageView, RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
builder.addContentView(imageView, RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||||
builder.show()
|
builder.show()
|
||||||
}
|
}
|
||||||
@ -94,60 +112,74 @@ internal class AppAdapter(val context: Context?) : HeaderAdapter<AppItem, BaseHe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This returns the view for an element at a specific position
|
* The ViewHolder used by items is used to hold the views associated with an item
|
||||||
*
|
|
||||||
* @param position The position of the requested item
|
|
||||||
* @param convertView An existing view (If any)
|
|
||||||
* @param parent The parent view group used for layout inflation
|
|
||||||
*/
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
|
||||||
var view = convertView
|
|
||||||
val viewHolder: ViewHolder
|
|
||||||
val item = elementArray[visibleArray[position]]
|
|
||||||
|
|
||||||
if (view == null) {
|
|
||||||
viewHolder = ViewHolder()
|
|
||||||
if (item is AppItem) {
|
|
||||||
val inflater = LayoutInflater.from(context)
|
|
||||||
view = inflater.inflate(R.layout.game_item, parent, false)
|
|
||||||
|
|
||||||
viewHolder.icon = view.findViewById(R.id.icon)
|
|
||||||
viewHolder.title = view.findViewById(R.id.text_title)
|
|
||||||
viewHolder.subtitle = view.findViewById(R.id.text_subtitle)
|
|
||||||
view.tag = viewHolder
|
|
||||||
} else if (item is BaseHeader) {
|
|
||||||
val inflater = LayoutInflater.from(context)
|
|
||||||
view = inflater.inflate(R.layout.section_item, parent, false)
|
|
||||||
|
|
||||||
viewHolder.title = view.findViewById(R.id.text_title)
|
|
||||||
view.tag = viewHolder
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewHolder = view.tag as ViewHolder
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item is AppItem) {
|
|
||||||
val data = getItem(position) as AppItem
|
|
||||||
|
|
||||||
viewHolder.title!!.text = data.title
|
|
||||||
viewHolder.subtitle!!.text = data.subTitle ?: context?.getString(R.string.metadata_missing)!!
|
|
||||||
|
|
||||||
viewHolder.icon!!.setImageBitmap(data.icon ?: context!!.resources.getDrawable(R.drawable.ic_missing, context.theme).toBitmap(256, 256))
|
|
||||||
viewHolder.icon!!.setOnClickListener(this)
|
|
||||||
viewHolder.icon!!.tag = position
|
|
||||||
} else {
|
|
||||||
viewHolder.title!!.text = (getItem(position) as BaseHeader).title
|
|
||||||
}
|
|
||||||
|
|
||||||
return view!!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ViewHolder object is used to hold the views associated with an object
|
|
||||||
*
|
*
|
||||||
|
* @param parent The parent view that contains all the others
|
||||||
* @param icon The ImageView associated with the icon
|
* @param icon The ImageView associated with the icon
|
||||||
* @param title The TextView associated with the title
|
* @param title The TextView associated with the title
|
||||||
* @param subtitle The TextView associated with the subtitle
|
* @param subtitle The TextView associated with the subtitle
|
||||||
*/
|
*/
|
||||||
private class ViewHolder(var icon: ImageView? = null, var title: TextView? = null, var subtitle: TextView? = null)
|
private class ItemViewHolder(val parent: View, var icon: ImageView, var title: TextView, var subtitle: TextView, var card: View? = null) : RecyclerView.ViewHolder(parent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ViewHolder used by headers is used to hold the views associated with an headers
|
||||||
|
*
|
||||||
|
* @param parent The parent view that contains all the others
|
||||||
|
* @param header The TextView associated with the header
|
||||||
|
*/
|
||||||
|
private class HeaderViewHolder(val parent: View, var header: TextView? = null) : RecyclerView.ViewHolder(parent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates the view-holder of type [viewType] with the layout parent as [parent]
|
||||||
|
*/
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(context)
|
||||||
|
var holder: RecyclerView.ViewHolder? = null
|
||||||
|
|
||||||
|
if (viewType == Item.ordinal) {
|
||||||
|
val view = inflater.inflate(R.layout.app_item_linear, parent, false)
|
||||||
|
holder = ItemViewHolder(view, view.findViewById(R.id.icon), view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle))
|
||||||
|
|
||||||
|
if (layoutType == LayoutType.List) {
|
||||||
|
if (context is View.OnClickListener)
|
||||||
|
view.setOnClickListener(context as View.OnClickListener)
|
||||||
|
|
||||||
|
if (context is View.OnLongClickListener)
|
||||||
|
view.setOnLongClickListener(context as View.OnLongClickListener)
|
||||||
|
}
|
||||||
|
} else if (viewType == Header.ordinal) {
|
||||||
|
val view = inflater.inflate(R.layout.section_item, parent, false)
|
||||||
|
holder = HeaderViewHolder(view)
|
||||||
|
|
||||||
|
holder.header = view.findViewById(R.id.text_title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return holder!!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function binds the item at [position] to the supplied [viewHolder]
|
||||||
|
*/
|
||||||
|
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val item = getItem(position)
|
||||||
|
|
||||||
|
if (item is AppItem) {
|
||||||
|
val holder = viewHolder as ItemViewHolder
|
||||||
|
|
||||||
|
holder.title.text = item.title
|
||||||
|
holder.subtitle.text = item.subTitle ?: missingString
|
||||||
|
|
||||||
|
holder.icon.setImageBitmap(item.icon ?: missingIcon)
|
||||||
|
|
||||||
|
holder.icon.setOnClickListener(this)
|
||||||
|
holder.icon.tag = position
|
||||||
|
|
||||||
|
holder.card?.tag = item
|
||||||
|
holder.parent.tag = item
|
||||||
|
} else if (item is BaseHeader) {
|
||||||
|
val holder = viewHolder as HeaderViewHolder
|
||||||
|
|
||||||
|
holder.header!!.text = item.title
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,10 @@
|
|||||||
package emu.skyline.adapter
|
package emu.skyline.adapter
|
||||||
|
|
||||||
import android.util.SparseIntArray
|
import android.util.SparseIntArray
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.BaseAdapter
|
|
||||||
import android.widget.Filter
|
import android.widget.Filter
|
||||||
import android.widget.Filterable
|
import android.widget.Filterable
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import info.debatty.java.stringsimilarity.Cosine
|
import info.debatty.java.stringsimilarity.Cosine
|
||||||
import info.debatty.java.stringsimilarity.JaroWinkler
|
import info.debatty.java.stringsimilarity.JaroWinkler
|
||||||
import java.io.*
|
import java.io.*
|
||||||
@ -18,6 +17,9 @@ import java.util.*
|
|||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An enumeration of the type of elements in this adapter
|
||||||
|
*/
|
||||||
enum class ElementType(val type: Int) {
|
enum class ElementType(val type: Int) {
|
||||||
Header(0x0),
|
Header(0x0),
|
||||||
Item(0x1)
|
Item(0x1)
|
||||||
@ -43,13 +45,30 @@ abstract class BaseItem : BaseElement(ElementType.Item) {
|
|||||||
abstract fun key(): String?
|
abstract fun key(): String?
|
||||||
}
|
}
|
||||||
|
|
||||||
internal abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?> : BaseAdapter(), Filterable, Serializable {
|
/**
|
||||||
|
* This adapter has the ability to have 2 types of elements specifically headers and items
|
||||||
|
*/
|
||||||
|
abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, ViewHolder : RecyclerView.ViewHolder?> : RecyclerView.Adapter<ViewHolder>(), Filterable, Serializable {
|
||||||
|
/**
|
||||||
|
* This holds all the elements in an array even if they may not be visible
|
||||||
|
*/
|
||||||
var elementArray: ArrayList<BaseElement?> = ArrayList()
|
var elementArray: ArrayList<BaseElement?> = ArrayList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This holds the indices of all the visible items in [elementArray]
|
||||||
|
*/
|
||||||
var visibleArray: ArrayList<Int> = ArrayList()
|
var visibleArray: ArrayList<Int> = ArrayList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This holds the search term if there is any, to filter any items added during a search
|
||||||
|
*/
|
||||||
private var searchTerm = ""
|
private var searchTerm = ""
|
||||||
|
|
||||||
fun addItem(element: ItemType) {
|
/**
|
||||||
elementArray.add(element)
|
* This functions adds [item] to [elementArray] and [visibleArray] based on the filter
|
||||||
|
*/
|
||||||
|
fun addItem(item: ItemType) {
|
||||||
|
elementArray.add(item)
|
||||||
if (searchTerm.isNotEmpty())
|
if (searchTerm.isNotEmpty())
|
||||||
filter.filter(searchTerm)
|
filter.filter(searchTerm)
|
||||||
else {
|
else {
|
||||||
@ -58,18 +77,19 @@ internal abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addHeader(element: HeaderType) {
|
/**
|
||||||
elementArray.add(element)
|
* This function adds [header] to [elementArray] and [visibleArray] based on if the filter is active
|
||||||
if (searchTerm.isNotEmpty())
|
*/
|
||||||
filter.filter(searchTerm)
|
fun addHeader(header: HeaderType) {
|
||||||
else {
|
elementArray.add(header)
|
||||||
|
if (searchTerm.isEmpty())
|
||||||
visibleArray.add(elementArray.size - 1)
|
visibleArray.add(elementArray.size - 1)
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
internal inner class State(val elementArray: ArrayList<BaseElement?>) : Serializable
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This serializes [elementArray] into [file]
|
||||||
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun save(file: File) {
|
fun save(file: File) {
|
||||||
val fileObj = FileOutputStream(file)
|
val fileObj = FileOutputStream(file)
|
||||||
@ -79,6 +99,9 @@ internal abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHea
|
|||||||
fileObj.close()
|
fileObj.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This reads in [elementArray] from [file]
|
||||||
|
*/
|
||||||
@Throws(IOException::class, ClassNotFoundException::class)
|
@Throws(IOException::class, ClassNotFoundException::class)
|
||||||
open fun load(file: File) {
|
open fun load(file: File) {
|
||||||
val fileObj = FileInputStream(file)
|
val fileObj = FileInputStream(file)
|
||||||
@ -90,82 +113,124 @@ internal abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHea
|
|||||||
filter.filter(searchTerm)
|
filter.filter(searchTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This clears the view by clearing [elementArray] and [visibleArray]
|
||||||
|
*/
|
||||||
fun clear() {
|
fun clear() {
|
||||||
elementArray.clear()
|
elementArray.clear()
|
||||||
visibleArray.clear()
|
visibleArray.clear()
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCount(): Int {
|
/**
|
||||||
return visibleArray.size
|
* This returns the amount of elements that should be drawn to the list
|
||||||
}
|
*/
|
||||||
|
override fun getItemCount(): Int = visibleArray.size
|
||||||
override fun getItem(index: Int): BaseElement? {
|
|
||||||
return elementArray[visibleArray[index]]
|
/**
|
||||||
}
|
* This returns a particular element at [position]
|
||||||
|
*/
|
||||||
override fun getItemId(position: Int): Long {
|
fun getItem(position: Int): BaseElement? {
|
||||||
return position.toLong()
|
return elementArray[visibleArray[position]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This returns the type of an element at the specified position
|
||||||
|
*
|
||||||
|
* @param position The position of the element
|
||||||
|
*/
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
return elementArray[visibleArray[position]]!!.elementType.type
|
return elementArray[visibleArray[position]]!!.elementType.type
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getViewTypeCount(): Int {
|
/**
|
||||||
return ElementType.values().size
|
* This returns an instance of the filter object which is used to search for items in the view
|
||||||
}
|
*/
|
||||||
|
|
||||||
abstract override fun getView(position: Int, convertView: View?, parent: ViewGroup): View
|
|
||||||
|
|
||||||
override fun getFilter(): Filter {
|
override fun getFilter(): Filter {
|
||||||
return object : Filter() {
|
return object : Filter() {
|
||||||
inner class ScoredItem(val score: Double, val index: Int, val item:String) {}
|
/**
|
||||||
|
* We use Jaro-Winkler distance for string similarity (https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance)
|
||||||
|
*/
|
||||||
|
private val jw = JaroWinkler()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use Cosine similarity for string similarity (https://en.wikipedia.org/wiki/Cosine_similarity)
|
||||||
|
*/
|
||||||
|
private val cos = Cosine()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to store the results of the item sorting
|
||||||
|
*
|
||||||
|
* @param score The score of this result
|
||||||
|
* @param index The index of this item
|
||||||
|
*/
|
||||||
|
inner class ScoredItem(val score: Double, val index: Int) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This sorts the items in [keyArray] in relation to how similar they are to [term]
|
||||||
|
*/
|
||||||
fun extractSorted(term: String, keyArray: ArrayList<String>): Array<ScoredItem> {
|
fun extractSorted(term: String, keyArray: ArrayList<String>): Array<ScoredItem> {
|
||||||
val scoredItems: MutableList<ScoredItem> = ArrayList()
|
val scoredItems: MutableList<ScoredItem> = ArrayList()
|
||||||
|
|
||||||
val jw = JaroWinkler()
|
keyArray.forEachIndexed { index, item ->
|
||||||
val cos = Cosine()
|
val similarity = (jw.similarity(term, item) + cos.similarity(term, item)) / 2
|
||||||
|
|
||||||
|
if (similarity != 0.0)
|
||||||
|
scoredItems.add(ScoredItem(similarity, index))
|
||||||
|
}
|
||||||
|
|
||||||
keyArray.forEachIndexed { index, item -> scoredItems.add(ScoredItem((jw.similarity(term, item) + cos.similarity(term, item)) / 2, index, item)) }
|
|
||||||
scoredItems.sortWith(compareByDescending { it.score })
|
scoredItems.sortWith(compareByDescending { it.score })
|
||||||
|
|
||||||
return scoredItems.toTypedArray()
|
return scoredItems.toTypedArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performFiltering(charSequence: CharSequence): FilterResults {
|
/**
|
||||||
|
* This performs filtering on the items in [elementArray] based on similarity to [term]
|
||||||
|
*/
|
||||||
|
override fun performFiltering(term: CharSequence): FilterResults {
|
||||||
val results = FilterResults()
|
val results = FilterResults()
|
||||||
searchTerm = (charSequence as String).toLowerCase(Locale.getDefault())
|
searchTerm = (term as String).toLowerCase(Locale.getDefault())
|
||||||
if (charSequence.isEmpty()) {
|
|
||||||
|
if (term.isEmpty()) {
|
||||||
results.values = elementArray.indices.toMutableList()
|
results.values = elementArray.indices.toMutableList()
|
||||||
results.count = elementArray.size
|
results.count = elementArray.size
|
||||||
} else {
|
} else {
|
||||||
val filterData = ArrayList<Int>()
|
val filterData = ArrayList<Int>()
|
||||||
|
|
||||||
val keyArray = ArrayList<String>()
|
val keyArray = ArrayList<String>()
|
||||||
val keyIndex = SparseIntArray()
|
val keyIndex = SparseIntArray()
|
||||||
|
|
||||||
for (index in elementArray.indices) {
|
for (index in elementArray.indices) {
|
||||||
val item = elementArray[index]!!
|
val item = elementArray[index]!!
|
||||||
|
|
||||||
if (item is BaseItem) {
|
if (item is BaseItem) {
|
||||||
keyIndex.append(keyArray.size, index)
|
keyIndex.append(keyArray.size, index)
|
||||||
keyArray.add(item.key()!!.toLowerCase(Locale.getDefault()))
|
keyArray.add(item.key()!!.toLowerCase(Locale.getDefault()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val topResults = extractSorted(searchTerm, keyArray)
|
val topResults = extractSorted(searchTerm, keyArray)
|
||||||
val avgScore = topResults.sumByDouble { it.score } / topResults.size
|
val avgScore = topResults.sumByDouble { it.score } / topResults.size
|
||||||
|
|
||||||
for (result in topResults)
|
for (result in topResults)
|
||||||
if (result.score > avgScore)
|
if (result.score > avgScore)
|
||||||
filterData.add(keyIndex[result.index])
|
filterData.add(keyIndex[result.index])
|
||||||
|
|
||||||
results.values = filterData
|
results.values = filterData
|
||||||
results.count = filterData.size
|
results.count = filterData.size
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This publishes the results that were calculated in [performFiltering] to the view
|
||||||
|
*/
|
||||||
override fun publishResults(charSequence: CharSequence, results: FilterResults) {
|
override fun publishResults(charSequence: CharSequence, results: FilterResults) {
|
||||||
if (results.values is ArrayList<*>) {
|
if (results.values is ArrayList<*>) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
visibleArray = results.values as ArrayList<Int>
|
visibleArray = results.values as ArrayList<Int>
|
||||||
|
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,77 +14,118 @@ import android.view.View.OnLongClickListener
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import emu.skyline.R
|
import emu.skyline.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to hold all data about a log entry
|
||||||
|
*/
|
||||||
internal class LogItem(val message: String, val level: String) : BaseItem() {
|
internal class LogItem(val message: String, val level: String) : BaseItem() {
|
||||||
|
/**
|
||||||
|
* The log message itself is used as the search key
|
||||||
|
*/
|
||||||
override fun key(): String? {
|
override fun key(): String? {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class LogAdapter internal constructor(val context: Context, val compact: Boolean, private val debug_level: Int, private val level_str: Array<String>) : HeaderAdapter<LogItem, BaseHeader>(), OnLongClickListener {
|
/**
|
||||||
|
* This adapter is used for displaying logs outputted by the application
|
||||||
|
*/
|
||||||
|
internal class LogAdapter internal constructor(val context: Context, val compact: Boolean, private val debug_level: Int, private val level_str: Array<String>) : HeaderAdapter<LogItem, BaseHeader, RecyclerView.ViewHolder>(), OnLongClickListener {
|
||||||
private val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
private val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function adds a line to this log adapter
|
||||||
|
*/
|
||||||
fun add(logLine: String) {
|
fun add(logLine: String) {
|
||||||
try {
|
try {
|
||||||
val logMeta = logLine.split("|", limit = 3)
|
val logMeta = logLine.split("|", limit = 3)
|
||||||
|
|
||||||
if (logMeta[0].startsWith("1")) {
|
if (logMeta[0].startsWith("1")) {
|
||||||
val level = logMeta[1].toInt()
|
val level = logMeta[1].toInt()
|
||||||
if (level > debug_level) return
|
if (level > debug_level) return
|
||||||
|
|
||||||
addItem(LogItem(logMeta[2].replace('\\', '\n'), level_str[level]))
|
addItem(LogItem(logMeta[2].replace('\\', '\n'), level_str[level]))
|
||||||
} else {
|
} else {
|
||||||
addHeader(BaseHeader(logMeta[1]))
|
addHeader(BaseHeader(logMeta[1]))
|
||||||
}
|
}
|
||||||
} catch (ignored: IndexOutOfBoundsException) {
|
} catch (ignored: IndexOutOfBoundsException) {
|
||||||
|
} catch (ignored: NumberFormatException) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ViewHolder used by items is used to hold the views associated with an item
|
||||||
|
*
|
||||||
|
* @param parent The parent view that contains all the others
|
||||||
|
* @param title The TextView associated with the title
|
||||||
|
* @param subtitle The TextView associated with the subtitle
|
||||||
|
*/
|
||||||
|
private class ItemViewHolder(val parent: View, var title: TextView, var subtitle: TextView? = null) : RecyclerView.ViewHolder(parent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ViewHolder used by headers is used to hold the views associated with an headers
|
||||||
|
*
|
||||||
|
* @param parent The parent view that contains all the others
|
||||||
|
* @param header The TextView associated with the header
|
||||||
|
*/
|
||||||
|
private class HeaderViewHolder(val parent: View, var header: TextView) : RecyclerView.ViewHolder(parent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The onLongClick handler for the supplied [view], used to copy a log into the clipboard
|
||||||
|
*/
|
||||||
override fun onLongClick(view: View): Boolean {
|
override fun onLongClick(view: View): Boolean {
|
||||||
val item = getItem((view.tag as ViewHolder).position) as LogItem
|
val item = view.tag as LogItem
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")"))
|
clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")"))
|
||||||
Toast.makeText(view.context, "Copied to clipboard", Toast.LENGTH_LONG).show()
|
Toast.makeText(view.context, "Copied to clipboard", Toast.LENGTH_LONG).show()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
/**
|
||||||
var view = convertView
|
* This function creates the view-holder of type [viewType] with the layout parent as [parent]
|
||||||
val viewHolder: ViewHolder
|
*/
|
||||||
val item = elementArray[visibleArray[position]]
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
if (view == null) {
|
|
||||||
viewHolder = ViewHolder()
|
|
||||||
val inflater = LayoutInflater.from(context)
|
val inflater = LayoutInflater.from(context)
|
||||||
if (item is LogItem) {
|
var holder: RecyclerView.ViewHolder? = null
|
||||||
|
|
||||||
|
if (viewType == ElementType.Item.ordinal) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
view = inflater.inflate(R.layout.log_item_compact, parent, false)
|
val view = inflater.inflate(R.layout.log_item_compact, parent, false)
|
||||||
viewHolder.txtTitle = view.findViewById(R.id.text_title)
|
holder = ItemViewHolder(view, view.findViewById(R.id.text_title))
|
||||||
} else {
|
|
||||||
view = inflater.inflate(R.layout.log_item, parent, false)
|
|
||||||
viewHolder.txtTitle = view.findViewById(R.id.text_title)
|
|
||||||
viewHolder.txtSub = view.findViewById(R.id.text_subtitle)
|
|
||||||
}
|
|
||||||
view.setOnLongClickListener(this)
|
view.setOnLongClickListener(this)
|
||||||
} else if (item is BaseHeader) {
|
|
||||||
view = inflater.inflate(R.layout.section_item, parent, false)
|
|
||||||
viewHolder.txtTitle = view.findViewById(R.id.text_title)
|
|
||||||
}
|
|
||||||
view!!.tag = viewHolder
|
|
||||||
} else {
|
} else {
|
||||||
viewHolder = view.tag as ViewHolder
|
val view = inflater.inflate(R.layout.log_item, parent, false)
|
||||||
|
holder = ItemViewHolder(view, view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle))
|
||||||
|
|
||||||
|
view.setOnLongClickListener(this)
|
||||||
}
|
}
|
||||||
|
} else if (viewType == ElementType.Header.ordinal) {
|
||||||
|
val view = inflater.inflate(R.layout.section_item, parent, false)
|
||||||
|
holder = HeaderViewHolder(view, view.findViewById(R.id.text_title))
|
||||||
|
}
|
||||||
|
|
||||||
|
return holder!!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function binds the item at [position] to the supplied [viewHolder]
|
||||||
|
*/
|
||||||
|
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val item = getItem(position)
|
||||||
|
|
||||||
if (item is LogItem) {
|
if (item is LogItem) {
|
||||||
viewHolder.txtTitle!!.text = item.message
|
val holder = viewHolder as ItemViewHolder
|
||||||
if (!compact) viewHolder.txtSub!!.text = item.level
|
|
||||||
|
holder.title.text = item.message
|
||||||
|
holder.subtitle?.text = item.level
|
||||||
|
|
||||||
|
holder.parent.tag = item
|
||||||
} else if (item is BaseHeader) {
|
} else if (item is BaseHeader) {
|
||||||
viewHolder.txtTitle!!.text = item.title
|
val holder = viewHolder as HeaderViewHolder
|
||||||
}
|
|
||||||
viewHolder.position = position
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ViewHolder {
|
holder.header.text = item.title
|
||||||
var txtTitle: TextView? = null
|
}
|
||||||
var txtSub: TextView? = null
|
|
||||||
var position = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
14
app/src/main/res/README.md
Normal file
14
app/src/main/res/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Credits (Drawables)
|
||||||
|
## [Material Design Icons](https://material.io/resources/icons) (Apache-2)
|
||||||
|
* ic_clear
|
||||||
|
* ic_log
|
||||||
|
* ic_open
|
||||||
|
* ic_play
|
||||||
|
* ic_refresh
|
||||||
|
* ic_search
|
||||||
|
* ic_settings
|
||||||
|
* ic_share
|
||||||
|
## [Default Icon](https://github.com/switchbrew/libnx/blob/master/nx/default_icon.jpg)
|
||||||
|
We've recieved permission to use the icon from it's author [jaames](https://github.com/jaames)
|
||||||
|
## Skyline Logo
|
||||||
|
Skyline's logo was designed by [PixelyIon](https://github.com/PixelyIon)
|
BIN
app/src/main/res/drawable/default_icon.jpg
Normal file
BIN
app/src/main/res/drawable/default_icon.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.1 KiB |
@ -1,7 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/app_item_linear"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="15dp">
|
android:padding="15dp">
|
||||||
|
|
||||||
@ -12,20 +17,21 @@
|
|||||||
android:layout_alignParentStart="true"
|
android:layout_alignParentStart="true"
|
||||||
android:layout_alignParentTop="false"
|
android:layout_alignParentTop="false"
|
||||||
android:layout_centerVertical="true"
|
android:layout_centerVertical="true"
|
||||||
android:contentDescription="@string/icon"
|
android:layout_marginEnd="10dp"
|
||||||
android:src="@drawable/ic_missing" />
|
android:contentDescription="@string/icon" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_title"
|
android:id="@+id/text_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="60dp"
|
android:layout_alignTop="@id/icon"
|
||||||
android:layout_marginTop="5dp"
|
android:layout_toEndOf="@id/icon"
|
||||||
android:textAppearance="?android:attr/textAppearanceListItem" />
|
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||||
|
tools:ignore="RelativeOverlap" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_subtitle"
|
android:id="@+id/text_subtitle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/text_title"
|
android:layout_below="@id/text_title"
|
||||||
android:layout_alignStart="@id/text_title"
|
android:layout_alignStart="@id/text_title"
|
@ -18,10 +18,10 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<ListView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/log_list"
|
android:id="@+id/log_list"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
android:fastScrollEnabled="true"
|
android:fastScrollEnabled="true"
|
||||||
android:transcriptMode="normal"
|
android:transcriptMode="normal"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackground"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="6dp">
|
android:padding="1dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_title"
|
android:id="@+id/text_title"
|
||||||
|
@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
<androidx.appcompat.widget.Toolbar
|
<androidx.appcompat.widget.Toolbar
|
||||||
android:id="@+id/toolbar"
|
android:id="@+id/toolbar"
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/app_list"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/colorPrimary"
|
android:background="?attr/colorPrimary"
|
||||||
|
@ -3,8 +3,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:clickable="false"
|
android:clickable="false"
|
||||||
android:orientation="vertical"
|
android:padding="10dp">
|
||||||
android:padding="12dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_title"
|
android:id="@+id/text_title"
|
||||||
@ -14,6 +13,6 @@
|
|||||||
android:layout_marginTop="2dp"
|
android:layout_marginTop="2dp"
|
||||||
android:layout_marginEnd="5dp"
|
android:layout_marginEnd="5dp"
|
||||||
android:textColor="?colorSecondary"
|
android:textColor="?colorSecondary"
|
||||||
android:textSize="13sp" />
|
android:textSize="15sp" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
Loading…
Reference in New Issue
Block a user