diff --git a/app/src/main/java/emu/skyline/EmulationActivity.kt b/app/src/main/java/emu/skyline/EmulationActivity.kt index b783cc10..e8d2cbff 100644 --- a/app/src/main/java/emu/skyline/EmulationActivity.kt +++ b/app/src/main/java/emu/skyline/EmulationActivity.kt @@ -15,7 +15,7 @@ import android.view.SurfaceHolder import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import emu.skyline.loader.getRomFormat -import kotlinx.android.synthetic.main.game_activity.* +import kotlinx.android.synthetic.main.app_activity.* import java.io.File /** diff --git a/app/src/main/java/emu/skyline/LogActivity.kt b/app/src/main/java/emu/skyline/LogActivity.kt index df80aa30..66f14c7d 100644 --- a/app/src/main/java/emu/skyline/LogActivity.kt +++ b/app/src/main/java/emu/skyline/LogActivity.kt @@ -15,7 +15,11 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView 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 kotlinx.android.synthetic.main.log_activity.* import org.json.JSONObject import java.io.* import java.net.URL @@ -32,10 +36,19 @@ class LogActivity : AppCompatActivity() { setContentView(R.layout.log_activity) setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.setDisplayHomeAsUpEnabled(true) + val prefs = PreferenceManager.getDefaultSharedPreferences(this) - val logList = findViewById(R.id.log_list) - adapter = LogAdapter(this, prefs.getBoolean("log_compact", false), prefs.getString("log_level", "3")!!.toInt(), resources.getStringArray(R.array.log_level)) - logList.adapter = adapter + val compact = prefs.getBoolean("log_compact", false) + val logLevel = prefs.getString("log_level", "3")!!.toInt() + + 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 { logFile = File("${applicationInfo.dataDir}/skyline.log") logFile.forEachLine { diff --git a/app/src/main/java/emu/skyline/MainActivity.kt b/app/src/main/java/emu/skyline/MainActivity.kt index 41b190dc..eeaa28d0 100644 --- a/app/src/main/java/emu/skyline/MainActivity.kt +++ b/app/src/main/java/emu/skyline/MainActivity.kt @@ -18,6 +18,9 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.SearchView import androidx.documentfile.provider.DocumentFile 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 emu.skyline.adapter.AppAdapter import emu.skyline.adapter.AppItem @@ -128,12 +131,12 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { setSupportActionBar(toolbar) open_fab.setOnClickListener(this) log_fab.setOnClickListener(this) - game_list.adapter = adapter - game_list.onItemClickListener = OnItemClickListener { parent: AdapterView<*>, _: View?, position: Int, _: Long -> - val item = parent.getItemAtPosition(position) - if (item is AppItem) { - val intent = Intent(this, EmulationActivity::class.java) - intent.data = item.uri + adapter = AppAdapter(this) + app_list.adapter = adapter + + app_list.layoutManager = LinearLayoutManager(this) + app_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) + startActivity(intent) } } @@ -181,9 +184,35 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { intent.type = "*/*" 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 { return when (item.itemId) { R.id.action_settings -> { diff --git a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt index d92bb6c6..27f175ce 100644 --- a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt +++ b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt @@ -19,7 +19,10 @@ import android.widget.ImageView import android.widget.RelativeLayout import android.widget.TextView import androidx.core.graphics.drawable.toBitmap +import androidx.recyclerview.widget.RecyclerView import emu.skyline.R +import emu.skyline.adapter.ElementType.Header +import emu.skyline.adapter.ElementType.Item import emu.skyline.loader.AppEntry /** @@ -36,7 +39,7 @@ class AppItem(val meta: AppEntry) : BaseItem() { * The title of the application */ val title: String - get() = meta.name + " (" + type + ")" + get() = meta.name /** * 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 get() = meta.format.name + /** + * The name and author is used as the key + */ override fun key(): String? { 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 */ -internal class AppAdapter(val context: Context?) : HeaderAdapter(), View.OnClickListener { +internal class AppAdapter(val context: Context?) : HeaderAdapter(), 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) { super.addHeader(BaseHeader(string)) } /** - * The onClick handler, it's for displaying the icon preview - * - * @param view The specific view that was clicked + * The onClick handler for the supplied [view], used for the icon preview */ override fun onClick(view: View) { val position = view.tag as Int + if (getItem(position) is AppItem) { val item = getItem(position) as AppItem + if (view.id == R.id.icon) { val builder = Dialog(context!!) builder.requestWindowFeature(Window.FEATURE_NO_TITLE) builder.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + 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.show() } @@ -94,60 +112,74 @@ internal class AppAdapter(val context: Context?) : HeaderAdapter : BaseAdapter(), Filterable, Serializable { +/** + * This adapter has the ability to have 2 types of elements specifically headers and items + */ +abstract class HeaderAdapter : RecyclerView.Adapter(), Filterable, Serializable { + /** + * This holds all the elements in an array even if they may not be visible + */ var elementArray: ArrayList = ArrayList() + + /** + * This holds the indices of all the visible items in [elementArray] + */ var visibleArray: ArrayList = ArrayList() + + /** + * This holds the search term if there is any, to filter any items added during a search + */ 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()) filter.filter(searchTerm) else { @@ -58,18 +77,19 @@ internal abstract class HeaderAdapter) : Serializable - + /** + * This serializes [elementArray] into [file] + */ @Throws(IOException::class) fun save(file: File) { val fileObj = FileOutputStream(file) @@ -79,6 +99,9 @@ internal abstract class HeaderAdapter): Array { - val scoredItems : MutableList = ArrayList() + val scoredItems: MutableList = ArrayList() - val jw = JaroWinkler() - val cos = Cosine() + keyArray.forEachIndexed { index, item -> + 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 }) 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() - searchTerm = (charSequence as String).toLowerCase(Locale.getDefault()) - if (charSequence.isEmpty()) { + searchTerm = (term as String).toLowerCase(Locale.getDefault()) + + if (term.isEmpty()) { results.values = elementArray.indices.toMutableList() results.count = elementArray.size } else { val filterData = ArrayList() + val keyArray = ArrayList() val keyIndex = SparseIntArray() + for (index in elementArray.indices) { val item = elementArray[index]!! + if (item is BaseItem) { keyIndex.append(keyArray.size, index) keyArray.add(item.key()!!.toLowerCase(Locale.getDefault())) } } + val topResults = extractSorted(searchTerm, keyArray) val avgScore = topResults.sumByDouble { it.score } / topResults.size + for (result in topResults) if (result.score > avgScore) filterData.add(keyIndex[result.index]) + results.values = filterData results.count = filterData.size } + return results } + /** + * This publishes the results that were calculated in [performFiltering] to the view + */ override fun publishResults(charSequence: CharSequence, results: FilterResults) { if (results.values is ArrayList<*>) { @Suppress("UNCHECKED_CAST") visibleArray = results.values as ArrayList + notifyDataSetChanged() } } diff --git a/app/src/main/java/emu/skyline/adapter/LogAdapter.kt b/app/src/main/java/emu/skyline/adapter/LogAdapter.kt index 55b6ea22..e54e0109 100644 --- a/app/src/main/java/emu/skyline/adapter/LogAdapter.kt +++ b/app/src/main/java/emu/skyline/adapter/LogAdapter.kt @@ -14,77 +14,118 @@ import android.view.View.OnLongClickListener import android.view.ViewGroup import android.widget.TextView import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView 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() { + /** + * The log message itself is used as the search key + */ override fun key(): String? { return message } } -internal class LogAdapter internal constructor(val context: Context, val compact: Boolean, private val debug_level: Int, private val level_str: Array) : HeaderAdapter(), 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) : HeaderAdapter(), OnLongClickListener { private val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + /** + * This function adds a line to this log adapter + */ fun add(logLine: String) { try { val logMeta = logLine.split("|", limit = 3) + if (logMeta[0].startsWith("1")) { val level = logMeta[1].toInt() if (level > debug_level) return + addItem(LogItem(logMeta[2].replace('\\', '\n'), level_str[level])) } else { addHeader(BaseHeader(logMeta[1])) } } 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 { - 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 + ")")) Toast.makeText(view.context, "Copied to clipboard", Toast.LENGTH_LONG).show() return false } - 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() - val inflater = LayoutInflater.from(context) - if (item is LogItem) { - if (compact) { - view = inflater.inflate(R.layout.log_item_compact, parent, false) - viewHolder.txtTitle = 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) - } + /** + * 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 == ElementType.Item.ordinal) { + if (compact) { + val view = inflater.inflate(R.layout.log_item_compact, parent, false) + holder = ItemViewHolder(view, view.findViewById(R.id.text_title)) + + view.setOnLongClickListener(this) + } else { + 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 (item is BaseHeader) { - view = inflater.inflate(R.layout.section_item, parent, false) - viewHolder.txtTitle = view.findViewById(R.id.text_title) } - view!!.tag = viewHolder - } else { - viewHolder = view.tag as ViewHolder + } 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) { - viewHolder.txtTitle!!.text = item.message - if (!compact) viewHolder.txtSub!!.text = item.level + val holder = viewHolder as ItemViewHolder + + holder.title.text = item.message + holder.subtitle?.text = item.level + + holder.parent.tag = item } else if (item is BaseHeader) { - viewHolder.txtTitle!!.text = item.title + val holder = viewHolder as HeaderViewHolder + + holder.header.text = item.title } - viewHolder.position = position - return view } - - private class ViewHolder { - var txtTitle: TextView? = null - var txtSub: TextView? = null - var position = 0 - } - } diff --git a/app/src/main/res/README.md b/app/src/main/res/README.md new file mode 100644 index 00000000..297dbfb4 --- /dev/null +++ b/app/src/main/res/README.md @@ -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) diff --git a/app/src/main/res/drawable/default_icon.jpg b/app/src/main/res/drawable/default_icon.jpg new file mode 100644 index 00000000..859caf4a Binary files /dev/null and b/app/src/main/res/drawable/default_icon.jpg differ diff --git a/app/src/main/res/layout/game_activity.xml b/app/src/main/res/layout/app_activity.xml similarity index 100% rename from app/src/main/res/layout/game_activity.xml rename to app/src/main/res/layout/app_activity.xml diff --git a/app/src/main/res/layout/game_item.xml b/app/src/main/res/layout/app_item_linear.xml similarity index 66% rename from app/src/main/res/layout/game_item.xml rename to app/src/main/res/layout/app_item_linear.xml index cb0a99de..c275b7e6 100644 --- a/app/src/main/res/layout/game_item.xml +++ b/app/src/main/res/layout/app_item_linear.xml @@ -1,7 +1,12 @@ @@ -12,20 +17,21 @@ android:layout_alignParentStart="true" android:layout_alignParentTop="false" android:layout_centerVertical="true" - android:contentDescription="@string/icon" - android:src="@drawable/ic_missing" /> + android:layout_marginEnd="10dp" + android:contentDescription="@string/icon" /> + android:layout_alignTop="@id/icon" + android:layout_toEndOf="@id/icon" + android:textAppearance="?android:attr/textAppearanceListItem" + tools:ignore="RelativeOverlap" /> - + android:padding="1dp"> + android:padding="10dp"> + android:textSize="15sp" />