diff --git a/app/build.gradle b/app/build.gradle index 3de56649..e584fc7f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -68,6 +68,7 @@ android { /* NDK */ ndkVersion '22.0.7026061' + externalNativeBuild { cmake { version '3.18.1+' @@ -85,15 +86,22 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) /* Google */ + def lifecycle_version = "2.2.0" + + implementation "androidx.core:core-ktx:1.3.2" implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.documentfile:documentfile:1.0.1' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" + implementation 'androidx.fragment:fragment-ktx:1.2.5' /* Kotlin */ - implementation "androidx.core:core-ktx:1.3.2" - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" /* Other Java */ implementation 'info.debatty:java-string-similarity:2.0.0' diff --git a/app/src/main/java/emu/skyline/EmulationActivity.kt b/app/src/main/java/emu/skyline/EmulationActivity.kt index 28d63bb9..0d4fc8c6 100644 --- a/app/src/main/java/emu/skyline/EmulationActivity.kt +++ b/app/src/main/java/emu/skyline/EmulationActivity.kt @@ -38,12 +38,6 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo */ private var vibrators = HashMap() - /** - * The surface object used for displaying frames - */ - @Volatile - private var surface : Surface? = null - /** * A boolean flag denoting if the emulation thread should call finish() or not */ @@ -262,14 +256,10 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo vibrators.clear() } - /** - * This sets [surface] to [holder].surface and passes it into libskyline - */ override fun surfaceCreated(holder : SurfaceHolder) { Log.d(Tag, "surfaceCreated Holder: $holder") - surface = holder.surface while (emulationThread.isAlive) - if (setSurface(surface)) + if (setSurface(holder.surface)) return } @@ -280,14 +270,10 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback, View.OnTo Log.d(Tag, "surfaceChanged Holder: $holder, Format: $format, Width: $width, Height: $height") } - /** - * This sets [surface] to null and passes it into libskyline - */ override fun surfaceDestroyed(holder : SurfaceHolder) { Log.d(Tag, "surfaceDestroyed Holder: $holder") - surface = null while (emulationThread.isAlive) - if (setSurface(surface)) + if (setSurface(null)) return } diff --git a/app/src/main/java/emu/skyline/KeyReader.kt b/app/src/main/java/emu/skyline/KeyReader.kt index 5d9ba695..4198dadc 100644 --- a/app/src/main/java/emu/skyline/KeyReader.kt +++ b/app/src/main/java/emu/skyline/KeyReader.kt @@ -32,54 +32,49 @@ object KeyReader { return false val fileName = DocumentFile.fromSingleUri(context, uri)!!.name - if (fileName?.substringAfterLast('.')?.startsWith("keys")?.not() ?: false) + if (fileName?.substringAfterLast('.')?.startsWith("keys")?.not() == true) return false val tmpOutputFile = File("${context.filesDir.canonicalFile}/${keyType.fileName}.tmp") val inputStream = context.contentResolver.openInputStream(uri) - val outputStream = tmpOutputFile.bufferedWriter() + tmpOutputFile.bufferedWriter().use { writer -> + val valid = inputStream!!.bufferedReader().useLines { + for (line in it) { + val pair = line.split("=") + if (pair.size != 2) + return@useLines false - val valid = inputStream!!.bufferedReader().useLines { - for (line in it) { - val pair = line.split("=") - if (pair.size != 2) - return@useLines false + val key = pair[0].trim() + val value = pair[1].trim() + when (keyType) { + KeyType.Title -> { + if (key.length != 32 && !isHexString(key)) + return@useLines false + if (value.length != 32 && !isHexString(value)) + return@useLines false + } + KeyType.Prod -> { + if (!key.contains("_")) + return@useLines false + if (!isHexString(value)) + return@useLines false + } + } - val key = pair[0].trim() - val value = pair[1].trim() - when (keyType) { - KeyType.Title -> { - if (key.length != 32 && !isHexString(key)) - return@useLines false - if (value.length != 32 && !isHexString(value)) - return@useLines false - } - KeyType.Prod -> { - if (!key.contains("_")) - return@useLines false - if (!isHexString(value)) - return@useLines false - } + writer.append("$key=$value\n") } - - outputStream.append("$key=$value\n") + true } - true + + if (valid) tmpOutputFile.renameTo(File("${tmpOutputFile.parent}/${keyType.fileName}")) + return valid } - - outputStream.flush() - outputStream.close() - - if (valid) - tmpOutputFile.renameTo(File("${tmpOutputFile.parent}/${keyType.fileName}")) - return valid } private fun isHexString(str : String) : Boolean { for (c in str) - if (!(c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F')) - return false + if (!(c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F')) return false return true } } diff --git a/app/src/main/java/emu/skyline/LogActivity.kt b/app/src/main/java/emu/skyline/LogActivity.kt index 64919ecb..61f2de3f 100644 --- a/app/src/main/java/emu/skyline/LogActivity.kt +++ b/app/src/main/java/emu/skyline/LogActivity.kt @@ -66,22 +66,23 @@ class LogActivity : AppCompatActivity() { try { logFile = File(applicationContext.filesDir.canonicalPath + "/skyline.log") - logFile.forEachLine { logLine -> + adapter.setItems(logFile.readLines().mapNotNull { logLine -> try { val logMeta = logLine.split("|", limit = 3) if (logMeta[0].startsWith("1")) { val level = logMeta[1].toInt() - if (level > logLevel) return@forEachLine + if (level > logLevel) return@mapNotNull null - adapter.addItem(LogViewItem(compact, "(" + logMeta[2] + ") " + logMeta[3].replace('\\', '\n'), logLevels[level])) + return@mapNotNull LogViewItem(compact, "(" + logMeta[2] + ") " + logMeta[3].replace('\\', '\n'), logLevels[level]) } else { - adapter.addItem(HeaderViewItem(logMeta[1])) + return@mapNotNull HeaderViewItem(logMeta[1]) } } catch (ignored : IndexOutOfBoundsException) { } catch (ignored : NumberFormatException) { } - } + null + }) } catch (e : FileNotFoundException) { Log.w("Logger", "IO Error during access of log file: " + e.message) Toast.makeText(applicationContext, getString(R.string.file_missing), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/emu/skyline/MainActivity.kt b/app/src/main/java/emu/skyline/MainActivity.kt index 4cbcce9a..9d821ec3 100644 --- a/app/src/main/java/emu/skyline/MainActivity.kt +++ b/app/src/main/java/emu/skyline/MainActivity.kt @@ -5,22 +5,20 @@ package emu.skyline -import android.animation.ObjectAnimator import android.content.Intent -import android.content.pm.ActivityInfo +import android.graphics.Color +import android.graphics.Rect import android.net.Uri import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem import android.view.View +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.appcompat.widget.SearchView -import androidx.core.animation.doOnEnd import androidx.core.content.ContextCompat +import androidx.core.content.res.use import androidx.core.graphics.drawable.toBitmap -import androidx.documentfile.provider.DocumentFile +import androidx.core.view.size +import androidx.lifecycle.observe import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager @@ -34,222 +32,137 @@ import emu.skyline.data.AppItem import emu.skyline.data.DataItem import emu.skyline.data.HeaderItem import emu.skyline.loader.LoaderResult -import emu.skyline.loader.RomFile -import emu.skyline.loader.RomFormat import emu.skyline.utils.Settings -import emu.skyline.utils.loadSerializedList -import emu.skyline.utils.serialize import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.titlebar.* -import java.io.File -import java.io.IOException -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.concurrent.thread import kotlin.math.ceil class MainActivity : AppCompatActivity() { - companion object { - private val TAG = MainActivity::class.java.simpleName - } - private val settings by lazy { Settings(this) } - /** - * The adapter used for adding elements to [app_list] - */ private val adapter = GenericAdapter() - private var reloading = AtomicBoolean() - private val layoutType get() = LayoutType.values()[settings.layoutType.toInt()] private val missingIcon by lazy { ContextCompat.getDrawable(this, R.drawable.default_icon)!!.toBitmap(256, 256) } + private val viewModel by viewModels() + private fun AppItem.toViewItem() = AppViewItem(layoutType, this, missingIcon, ::selectStartGame, ::selectShowGameDialog) - /** - * This adds all files in [directory] with [extension] as an entry in [adapter] using [RomFile] to load metadata - */ - private fun addEntries(extension : String, romFormat : RomFormat, directory : DocumentFile, romElements : ArrayList, found : Boolean = false) : Boolean { - var foundCurrent = found - - directory.listFiles().forEach { file -> - if (file.isDirectory) { - foundCurrent = addEntries(extension, romFormat, file, romElements, foundCurrent) - } else { - if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) { - RomFile(this, romFormat, file.uri).let { romFile -> - val finalFoundCurrent = foundCurrent - runOnUiThread { - if (!finalFoundCurrent) { - romElements.add(HeaderItem(romFormat.name)) - adapter.addItem(HeaderViewItem(romFormat.name)) - } - - romElements.add(AppItem(romFile.appEntry).also { - adapter.addItem(it.toViewItem()) - }) - } - - foundCurrent = true - } - } - } - } - - return foundCurrent - } - - /** - * This refreshes the contents of the adapter by either trying to load cached adapter data or searches for them to recreate a list - * - * @param loadFromFile If this is false then trying to load cached adapter data is skipped entirely - */ - private fun refreshAdapter(loadFromFile : Boolean) { - val romsFile = File(applicationContext.filesDir.canonicalPath + "/roms.bin") - - if (loadFromFile) { - try { - loadSerializedList(romsFile).forEach { - if (it is HeaderItem) - adapter.addItem(HeaderViewItem(it.title)) - else if (it is AppItem) - adapter.addItem(it.toViewItem()) - } - return - } catch (e : Exception) { - Log.w(TAG, "Ran into exception while loading: ${e.message}") - } - } - - if (reloading.getAndSet(true)) return - thread(start = true) { - val snackbar = Snackbar.make(coordinatorLayout, getString(R.string.searching_roms), Snackbar.LENGTH_INDEFINITE) - runOnUiThread { - snackbar.show() - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED - } - - try { - runOnUiThread { adapter.removeAllItems() } - - val searchLocation = DocumentFile.fromTreeUri(this, Uri.parse(settings.searchLocation))!! - - val romElements = ArrayList() - addEntries("nro", RomFormat.NRO, searchLocation, romElements) - addEntries("nso", RomFormat.NSO, searchLocation, romElements) - addEntries("nca", RomFormat.NCA, searchLocation, romElements) - addEntries("xci", RomFormat.XCI, searchLocation, romElements) - addEntries("nsp", RomFormat.NSP, searchLocation, romElements) - - runOnUiThread { - if (romElements.isEmpty()) { - romElements.add(HeaderItem(getString(R.string.no_rom))) - adapter.addItem(HeaderViewItem(getString(R.string.no_rom))) - } - - try { - romElements.serialize(romsFile) - } catch (e : IOException) { - Log.w(TAG, "Ran into exception while saving: ${e.message}") - } - } - - settings.refreshRequired = false - } catch (e : IllegalArgumentException) { - runOnUiThread { - settings.searchLocation = "" - - val intent = intent - finish() - startActivity(intent) - } - } catch (e : Exception) { - runOnUiThread { - Snackbar.make(findViewById(android.R.id.content), getString(R.string.error) + ": ${e.localizedMessage}", Snackbar.LENGTH_SHORT).show() - } - } - - runOnUiThread { - snackbar.dismiss() - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - - reloading.set(false) - } - } - - /** - * This initializes [toolbar], [open_fab], [log_fab] and [app_list] - */ override fun onCreate(savedInstanceState : Bundle?) { + AppCompatDelegate.setDefaultNightMode( + when ((settings.appTheme.toInt())) { + 0 -> AppCompatDelegate.MODE_NIGHT_NO + 1 -> AppCompatDelegate.MODE_NIGHT_YES + 2 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED + } + ) + super.onCreate(savedInstanceState) setContentView(R.layout.main_activity) - setSupportActionBar(toolbar) - PreferenceManager.setDefaultValues(this, R.xml.preferences, false) - AppCompatDelegate.setDefaultNightMode(when ((settings.appTheme.toInt())) { - 0 -> AppCompatDelegate.MODE_NIGHT_NO - 1 -> AppCompatDelegate.MODE_NIGHT_YES - 2 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED - }) - - refresh_fab.setOnClickListener { refreshAdapter(false) } - - settings_fab.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), 3) } - - open_fab.setOnClickListener { - startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - }, 2) - } - - log_fab.setOnClickListener { startActivity(Intent(this, LogActivity::class.java)) } - setupAppList() - app_list.addOnScrollListener(object : RecyclerView.OnScrollListener() { - var y = 0 + swipe_refresh_layout.apply { + setProgressBackgroundColorSchemeColor(obtainStyledAttributes(intArrayOf(R.attr.colorPrimary)).use { it.getColor(0, Color.BLACK) }) + setColorSchemeColors(obtainStyledAttributes(intArrayOf(R.attr.colorAccent)).use { it.getColor(0, Color.BLACK) }) + post { setDistanceToTriggerSync(swipe_refresh_layout.height / 3) } + setOnRefreshListener { loadRoms(false) } + } - override fun onScrolled(recyclerView : RecyclerView, dx : Int, dy : Int) { - y += dy + viewModel.state.observe(owner = this, onChanged = ::handleState) + loadRoms(!settings.refreshRequired) - if (!app_list.isInTouchMode) - toolbar_layout.setExpanded(y == 0) - - super.onScrolled(recyclerView, dx, dy) + search_bar.apply { + setLogIconListener { startActivity(Intent(context, LogActivity::class.java)) } + setSettingsIconListener { startActivityForResult(Intent(context, SettingsActivity::class.java), 3) } + setRefreshIconListener { loadRoms(false) } + addTextChangedListener(afterTextChanged = { editable -> + editable?.let { text -> adapter.filter.filter(text.toString()) } + }) + if (!viewModel.searchBarAnimated) { + viewModel.searchBarAnimated = true + post { startTitleAnimation() } } - }) + } + window.decorView.findViewById(android.R.id.content).viewTreeObserver.addOnTouchModeChangeListener { isInTouchMode -> + search_bar.refreshIconVisible = !isInTouchMode + } + } - val controllerFabX = controller_fabs.translationX - window.decorView.findViewById(android.R.id.content).viewTreeObserver.addOnTouchModeChangeListener { - if (!it) { - toolbar_layout.setExpanded(false) + private inner class GridSpacingItemDecoration : RecyclerView.ItemDecoration() { + private val padding = resources.getDimensionPixelSize(R.dimen.grid_padding) - controller_fabs.visibility = View.VISIBLE - ObjectAnimator.ofFloat(controller_fabs, "translationX", 0f).apply { - duration = 250 - start() + override fun getItemOffsets(outRect : Rect, view : View, parent : RecyclerView, state : RecyclerView.State) { + super.getItemOffsets(outRect, view, parent, state) + + val gridLayoutManager = parent.layoutManager as GridLayoutManager + val layoutParams = view.layoutParams as GridLayoutManager.LayoutParams + when (layoutParams.spanIndex) { + 0 -> outRect.left = padding + + gridLayoutManager.spanCount - 1 -> outRect.right = padding + + else -> { + outRect.left = padding / 2 + outRect.right = padding / 2 } - } else { - ObjectAnimator.ofFloat(controller_fabs, "translationX", controllerFabX).apply { - duration = 250 - start() - }.doOnEnd { controller_fabs.visibility = View.GONE } } + + if (layoutParams.spanSize == gridLayoutManager.spanCount) outRect.right = padding } } private fun setAppListDecoration() { + while (app_list.itemDecorationCount > 0) app_list.removeItemDecorationAt(0) when (layoutType) { LayoutType.List -> app_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) - LayoutType.Grid, LayoutType.GridCompact -> if (app_list.itemDecorationCount > 0) app_list.removeItemDecorationAt(0) + LayoutType.Grid, LayoutType.GridCompact -> app_list.addItemDecoration(GridSpacingItemDecoration()) + } + } + + /** + * This layout manager handles situations where [onFocusSearchFailed] gets called, when possible we always want to focus on the item with the same span index + */ + private inner class CustomLayoutManager(gridSpan : Int) : GridLayoutManager(this, gridSpan) { + init { + spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position : Int) = if (layoutType == LayoutType.List || adapter.currentItems[position] is HeaderViewItem) gridSpan else 1 + } + } + + override fun onFocusSearchFailed(focused : View, focusDirection : Int, recycler : RecyclerView.Recycler, state : RecyclerView.State) : View? { + val nextFocus = super.onFocusSearchFailed(focused, focusDirection, recycler, state) + when (focusDirection) { + View.FOCUS_DOWN -> { + findContainingItemView(focused)?.let { focusedChild -> + val current = app_list.indexOfChild(focusedChild) + val currentSpanIndex = (focusedChild.layoutParams as LayoutParams).spanIndex + for (i in current + 1 until app_list.size) { + val candidate = getChildAt(i)!! + // Return candidate when span index matches + if (currentSpanIndex == (candidate.layoutParams as LayoutParams).spanIndex) return candidate + } + if (nextFocus == null) { + app_bar_layout.setExpanded(false) // End of list, hide app bar, so bottom row is fully visible + app_list.smoothScrollToPosition(adapter.itemCount) + } + } + } + View.FOCUS_UP -> { + if (nextFocus?.isFocusable != true) { + search_bar.requestFocus() + app_bar_layout.setExpanded(true) + return null + } + } + } + return nextFocus } } @@ -260,11 +173,7 @@ class MainActivity : AppCompatActivity() { val metrics = resources.displayMetrics val gridSpan = ceil((metrics.widthPixels / metrics.density) / itemWidth).toInt() - app_list.layoutManager = GridLayoutManager(this, gridSpan).apply { - spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position : Int) = if (layoutType == LayoutType.List || adapter.currentItems[position] is HeaderViewItem) gridSpan else 1 - } - } + app_list.layoutManager = CustomLayoutManager(gridSpan) setAppListDecoration() if (settings.searchLocation.isEmpty()) { @@ -272,35 +181,24 @@ class MainActivity : AppCompatActivity() { intent.flags = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION startActivityForResult(intent, 1) - } else { - refreshAdapter(!settings.refreshRequired) } } - /** - * This inflates the layout for the menu [R.menu.toolbar_main] and sets up searching the logs - */ - override fun onCreateOptionsMenu(menu : Menu) : Boolean { - menuInflater.inflate(R.menu.toolbar_main, menu) - - val searchView = menu.findItem(R.id.action_search_main).actionView as SearchView - - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query : String) : Boolean { - searchView.clearFocus() - return false - } - - override fun onQueryTextChange(newText : String) : Boolean { - adapter.filter.filter(newText) - return true - } - }) - - return super.onCreateOptionsMenu(menu) + private fun handleState(state : MainState) = when (state) { + MainState.Loading -> { + search_bar.animateRefreshIcon() + swipe_refresh_layout.isRefreshing = true + } + is MainState.Loaded -> { + swipe_refresh_layout.isRefreshing = false + populateAdapter(state.items) + } + is MainState.Error -> Snackbar.make(findViewById(android.R.id.content), getString(R.string.error) + ": ${state.ex.localizedMessage}", Snackbar.LENGTH_SHORT).show() } private fun selectStartGame(appItem : AppItem) { + if (swipe_refresh_layout.isRefreshing) return + if (settings.selectAction) AppDialog.newInstance(appItem).show(supportFragmentManager, "game") else if (appItem.loaderResult == LoaderResult.Success) @@ -308,26 +206,24 @@ class MainActivity : AppCompatActivity() { } private fun selectShowGameDialog(appItem : AppItem) { + if (swipe_refresh_layout.isRefreshing) return + AppDialog.newInstance(appItem).show(supportFragmentManager, "game") } - /** - * This handles menu interaction for [R.id.action_settings] and [R.id.action_refresh] - */ - override fun onOptionsItemSelected(item : MenuItem) : Boolean { - return when (item.itemId) { - R.id.action_settings -> { - startActivityForResult(Intent(this, SettingsActivity::class.java), 3) - true - } + private fun loadRoms(loadFromFile : Boolean) { + viewModel.loadRoms(this, loadFromFile, Uri.parse(settings.searchLocation)) + settings.refreshRequired = false + } - R.id.action_refresh -> { - refreshAdapter(false) - true + private fun populateAdapter(items : List) { + adapter.setItems(items.map { + when (it) { + is HeaderItem -> HeaderViewItem(it.title) + is AppItem -> it.toViewItem() } - - else -> super.onOptionsItemSelected(item) - } + }) + if (items.isEmpty()) adapter.setItems(listOf(HeaderViewItem(getString(R.string.no_rom)))) } /** @@ -343,7 +239,7 @@ class MainActivity : AppCompatActivity() { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) settings.searchLocation = uri.toString() - refreshAdapter(!settings.refreshRequired) + loadRoms(!settings.refreshRequired) } 2 -> { @@ -360,9 +256,7 @@ class MainActivity : AppCompatActivity() { } } - 3 -> { - if (settings.refreshRequired) refreshAdapter(false) - } + 3 -> if (settings.refreshRequired) loadRoms(false) } } } @@ -371,7 +265,7 @@ class MainActivity : AppCompatActivity() { super.onResume() var layoutTypeChanged = false - for (appViewItem in adapter.allItems.filterIsInstance(AppViewItem::class.java)) { + for (appViewItem in adapter.currentItems.filterIsInstance(AppViewItem::class.java)) { if (layoutType != appViewItem.layoutType) { appViewItem.layoutType = layoutType layoutTypeChanged = true @@ -381,14 +275,19 @@ class MainActivity : AppCompatActivity() { } if (layoutTypeChanged) { - adapter.notifyAllItemsChanged() setAppListDecoration() + adapter.notifyItemRangeChanged(0, adapter.currentItems.size) } + } - val gridCardMagin = resources.getDimensionPixelSize(R.dimen.app_card_margin_half) - when (layoutType) { - LayoutType.List -> app_list.post { app_list.setPadding(0, 0, 0, fab_parent.height) } - LayoutType.Grid, LayoutType.GridCompact -> app_list.post { app_list.setPadding(gridCardMagin, 0, gridCardMagin, fab_parent.height) } + override fun onBackPressed() { + search_bar.apply { + if (hasFocus() && text.isNotEmpty()) { + text = "" + clearFocus() + } else { + super.onBackPressed() + } } } } diff --git a/app/src/main/java/emu/skyline/MainViewModel.kt b/app/src/main/java/emu/skyline/MainViewModel.kt new file mode 100644 index 00000000..f10b5fc8 --- /dev/null +++ b/app/src/main/java/emu/skyline/MainViewModel.kt @@ -0,0 +1,107 @@ +package emu.skyline + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import emu.skyline.data.AppItem +import emu.skyline.data.DataItem +import emu.skyline.data.HeaderItem +import emu.skyline.loader.RomFile +import emu.skyline.loader.RomFormat +import emu.skyline.utils.loadSerializedList +import emu.skyline.utils.serialize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +sealed class MainState { + object Loading : MainState() + class Loaded(val items : List) : MainState() + class Error(val ex : Exception) : MainState() +} + +class MainViewModel : ViewModel() { + companion object { + private val TAG = MainViewModel::class.java.simpleName + } + + private val mutableState = MutableLiveData() + val state : LiveData = mutableState + + var searchBarAnimated = false + + /** + * This adds all files in [directory] with [extension] as an entry using [RomFile] to load metadata + */ + private fun addEntries(context : Context, extension : String, romFormat : RomFormat, directory : DocumentFile, romElements : ArrayList, found : Boolean = false) : Boolean { + var foundCurrent = found + + directory.listFiles().forEach { file -> + if (file.isDirectory) { + foundCurrent = addEntries(context, extension, romFormat, file, romElements, foundCurrent) + } else { + if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) { + RomFile(context, romFormat, file.uri).let { romFile -> + if (!foundCurrent) romElements.add(HeaderItem(romFormat.name)) + romElements.add(AppItem(romFile.appEntry)) + + foundCurrent = true + } + } + } + } + + return foundCurrent + } + + /** + * This refreshes the contents of the adapter by either trying to load cached adapter data or searches for them to recreate a list + * + * @param loadFromFile If this is false then trying to load cached adapter data is skipped entirely + */ + fun loadRoms(context : Context, loadFromFile : Boolean, searchLocation : Uri) { + if (mutableState.value == MainState.Loading) return + + mutableState.postValue(MainState.Loading) + + val romsFile = File(context.filesDir.canonicalPath + "/roms.bin") + + viewModelScope.launch(Dispatchers.IO) { + if (loadFromFile) { + try { + mutableState.postValue(MainState.Loaded(loadSerializedList(romsFile))) + return@launch + } catch (e : Exception) { + Log.w(TAG, "Ran into exception while loading: ${e.message}") + } + } + + try { + val searchDocument = DocumentFile.fromTreeUri(context, searchLocation)!! + + val romElements = ArrayList() + addEntries(context, "nro", RomFormat.NRO, searchDocument, romElements) + addEntries(context, "nso", RomFormat.NSO, searchDocument, romElements) + addEntries(context, "nca", RomFormat.NCA, searchDocument, romElements) + addEntries(context, "xci", RomFormat.XCI, searchDocument, romElements) + addEntries(context, "nsp", RomFormat.NSP, searchDocument, romElements) + + try { + romElements.serialize(romsFile) + } catch (e : IOException) { + Log.w(TAG, "Ran into exception while saving: ${e.message}") + } + + mutableState.postValue(MainState.Loaded(romElements)) + } catch (e : Exception) { + mutableState.postValue(MainState.Error(e)) + } + } + } +} diff --git a/app/src/main/java/emu/skyline/SettingsActivity.kt b/app/src/main/java/emu/skyline/SettingsActivity.kt index 5092147e..f997a75c 100644 --- a/app/src/main/java/emu/skyline/SettingsActivity.kt +++ b/app/src/main/java/emu/skyline/SettingsActivity.kt @@ -13,7 +13,6 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceGroup import emu.skyline.preference.ActivityResultDelegate import emu.skyline.preference.DocumentActivity -import kotlinx.android.synthetic.main.settings_activity.* import kotlinx.android.synthetic.main.titlebar.* class SettingsActivity : AppCompatActivity() { @@ -46,8 +45,6 @@ class SettingsActivity : AppCompatActivity() { super.onActivityResult(requestCode, resultCode, data) preferenceFragment.delegateActivityResult(requestCode, resultCode, data) - - settings } /** @@ -101,4 +98,9 @@ class SettingsActivity : AppCompatActivity() { return super.onKeyUp(keyCode, event) } + + override fun finish() { + setResult(RESULT_OK) + super.finish() + } } diff --git a/app/src/main/java/emu/skyline/adapter/AppViewItem.kt b/app/src/main/java/emu/skyline/adapter/AppViewItem.kt index 5c37c206..b713087d 100644 --- a/app/src/main/java/emu/skyline/adapter/AppViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/AppViewItem.kt @@ -35,7 +35,7 @@ private data class AppLayoutFactory(private val layoutType : LayoutType) : Gener override fun createLayout(parent : ViewGroup) : View = LayoutInflater.from(parent.context).inflate(layoutType.layoutRes, parent, false) } -class AppViewItem(var layoutType : LayoutType, private val item : AppItem, private val missingIcon : Bitmap, private val onClick : InteractionFunction, private val onLongClick : InteractionFunction) : GenericViewHolderBinder() { +class AppViewItem(var layoutType : LayoutType, private val item : AppItem, private val missingIcon : Bitmap, private val onClick : InteractionFunction, private val onLongClick : InteractionFunction) : GenericListItem() { override fun getLayoutFactory() : GenericLayoutFactory = AppLayoutFactory(layoutType) override fun bind(holder : GenericViewHolder, position : Int) { @@ -48,10 +48,7 @@ class AppViewItem(var layoutType : LayoutType, private val item : AppItem, priva holder.icon.setOnClickListener { showIconDialog(holder.icon.context, item) } } - when (layoutType) { - LayoutType.List -> holder.itemView - LayoutType.Grid, LayoutType.GridCompact -> holder.card_app_item_grid - }.apply { + holder.itemView.findViewById(R.id.item_click_layout).apply { setOnClickListener { onClick.invoke(item) } setOnLongClickListener { true.also { onLongClick.invoke(item) } } } @@ -70,4 +67,8 @@ class AppViewItem(var layoutType : LayoutType, private val item : AppItem, priva } override fun key() = item.key() + + override fun areItemsTheSame(other : GenericListItem) = key() == other.key() + + override fun areContentsTheSame(other : GenericListItem) = other is AppViewItem && layoutType == other.layoutType && item == other.item } diff --git a/app/src/main/java/emu/skyline/adapter/GenericAdapter.kt b/app/src/main/java/emu/skyline/adapter/GenericAdapter.kt index 6eb09fc0..ff423423 100644 --- a/app/src/main/java/emu/skyline/adapter/GenericAdapter.kt +++ b/app/src/main/java/emu/skyline/adapter/GenericAdapter.kt @@ -8,20 +8,30 @@ package emu.skyline.adapter import android.view.ViewGroup import android.widget.Filter import android.widget.Filterable +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import info.debatty.java.stringsimilarity.Cosine import info.debatty.java.stringsimilarity.JaroWinkler import java.util.* /** - * Can handle any view types with [GenericViewHolderBinder] implemented, [GenericViewHolderBinder] are differentiated by the return value of [GenericViewHolderBinder.getLayoutFactory] + * Can handle any view types with [GenericListItem] implemented, [GenericListItem] are differentiated by the return value of [GenericListItem.getLayoutFactory] */ class GenericAdapter : RecyclerView.Adapter(), Filterable { - var currentSearchTerm = "" + companion object { + private val DIFFER = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem : GenericListItem, newItem : GenericListItem) = oldItem.areItemsTheSame(newItem) - val currentItems get() = if (currentSearchTerm.isEmpty()) allItems else filteredItems - val allItems = mutableListOf() - private var filteredItems = listOf() + override fun areContentsTheSame(oldItem : GenericListItem, newItem : GenericListItem) = oldItem.areContentsTheSame(newItem) + } + } + + private val asyncListDiffer = AsyncListDiffer(this, DIFFER) + private val allItems = mutableListOf() + val currentItems : List get() = asyncListDiffer.currentList + + var currentSearchTerm = "" private val viewTypesMapping = mutableMapOf() @@ -38,19 +48,10 @@ class GenericAdapter : RecyclerView.Adapter(), Filterable { override fun getItemViewType(position : Int) = viewTypesMapping.getOrPut(currentItems[position].getLayoutFactory(), { viewTypesMapping.size }) - fun addItem(item : GenericViewHolderBinder) { - allItems.add(item) - notifyItemInserted(currentItems.size) - } - - fun removeAllItems() { - val size = currentItems.size + fun setItems(items : List) { allItems.clear() - notifyItemRangeRemoved(0, size) - } - - fun notifyAllItemsChanged() { - notifyItemRangeChanged(0, currentItems.size) + allItems.addAll(items) + filter.filter(currentSearchTerm) } /** @@ -67,7 +68,7 @@ class GenericAdapter : RecyclerView.Adapter(), Filterable { */ private val cos = Cosine() - inner class ScoredItem(val score : Double, val item : GenericViewHolderBinder) + inner class ScoredItem(val score : Double, val item : GenericListItem) /** * This sorts the items in [allItems] in relation to how similar they are to [currentSearchTerm] @@ -92,7 +93,7 @@ class GenericAdapter : RecyclerView.Adapter(), Filterable { results.values = allItems.toMutableList() results.count = allItems.size } else { - val filterData = mutableListOf() + val filterData = mutableListOf() val topResults = extractSorted() val avgScore = topResults.sumByDouble { it.score } / topResults.size @@ -111,9 +112,7 @@ class GenericAdapter : RecyclerView.Adapter(), Filterable { */ override fun publishResults(charSequence : CharSequence, results : FilterResults) { @Suppress("UNCHECKED_CAST") - filteredItems = results.values as List - - notifyDataSetChanged() + asyncListDiffer.submitList(results.values as List) } } } \ No newline at end of file diff --git a/app/src/main/java/emu/skyline/adapter/GenericViewHolderBinder.kt b/app/src/main/java/emu/skyline/adapter/GenericListItem.kt similarity index 73% rename from app/src/main/java/emu/skyline/adapter/GenericViewHolderBinder.kt rename to app/src/main/java/emu/skyline/adapter/GenericListItem.kt index 66febe92..b4763f1e 100644 --- a/app/src/main/java/emu/skyline/adapter/GenericViewHolderBinder.kt +++ b/app/src/main/java/emu/skyline/adapter/GenericListItem.kt @@ -16,7 +16,7 @@ interface GenericLayoutFactory { fun createLayout(parent : ViewGroup) : View } -abstract class GenericViewHolderBinder { +abstract class GenericListItem { var adapter : GenericAdapter? = null abstract fun getLayoutFactory() : GenericLayoutFactory @@ -27,4 +27,11 @@ abstract class GenericViewHolderBinder { * Used for filtering */ open fun key() : String = "" + + open fun areItemsTheSame(other : GenericListItem) = this == other + + /** + * Will only be called when [areItemsTheSame] returns true, thus returning true by default + */ + open fun areContentsTheSame(other : GenericListItem) = true } diff --git a/app/src/main/java/emu/skyline/adapter/HeaderViewItem.kt b/app/src/main/java/emu/skyline/adapter/HeaderViewItem.kt index 176645fb..bd4595e3 100644 --- a/app/src/main/java/emu/skyline/adapter/HeaderViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/HeaderViewItem.kt @@ -15,7 +15,7 @@ private object HeaderLayoutFactory : GenericLayoutFactory { override fun createLayout(parent : ViewGroup) : View = LayoutInflater.from(parent.context).inflate(R.layout.section_item, parent, false) } -class HeaderViewItem(private val text : String) : GenericViewHolderBinder() { +class HeaderViewItem(private val text : String) : GenericListItem() { override fun getLayoutFactory() : GenericLayoutFactory = HeaderLayoutFactory override fun bind(holder : GenericViewHolder, position : Int) { @@ -23,4 +23,6 @@ class HeaderViewItem(private val text : String) : GenericViewHolderBinder() { } override fun toString() = "" + + override fun areItemsTheSame(other : GenericListItem) = other is HeaderViewItem && text == other.text } diff --git a/app/src/main/java/emu/skyline/adapter/LogViewItem.kt b/app/src/main/java/emu/skyline/adapter/LogViewItem.kt index c5debb76..f175057e 100644 --- a/app/src/main/java/emu/skyline/adapter/LogViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/LogViewItem.kt @@ -18,7 +18,7 @@ private data class LogLayoutFactory(private val compact : Boolean) : GenericLayo override fun createLayout(parent : ViewGroup) : View = LayoutInflater.from(parent.context).inflate(if (compact) R.layout.log_item_compact else R.layout.log_item, parent, false) } -class LogViewItem(private val compact : Boolean, private val message : String, private val level : String) : GenericViewHolderBinder() { +data class LogViewItem(private val compact : Boolean, private val message : String, private val level : String) : GenericListItem() { override fun getLayoutFactory() : GenericLayoutFactory = LogLayoutFactory(compact) override fun bind(holder : GenericViewHolder, position : Int) { diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerButtonViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerButtonViewItem.kt index bbebac8f..6d048cc1 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerButtonViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerButtonViewItem.kt @@ -5,6 +5,7 @@ package emu.skyline.adapter.controller +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.GenericViewHolder import emu.skyline.input.ButtonGuestEvent import emu.skyline.input.ButtonId @@ -23,4 +24,8 @@ class ControllerButtonViewItem(private val controllerId : Int, val button : Butt holder.itemView.setOnClickListener { onClick.invoke(this, position) } } + + override fun areItemsTheSame(other : GenericListItem) = other is ControllerButtonViewItem && controllerId == other.controllerId + + override fun areContentsTheSame(other : GenericListItem) = other is ControllerButtonViewItem && button == other.button } diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerCheckBoxViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerCheckBoxViewItem.kt index 9b7fc89b..b414eb20 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerCheckBoxViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerCheckBoxViewItem.kt @@ -12,14 +12,14 @@ import androidx.core.view.isGone import emu.skyline.R import emu.skyline.adapter.GenericLayoutFactory import emu.skyline.adapter.GenericViewHolder -import emu.skyline.adapter.GenericViewHolderBinder +import emu.skyline.adapter.GenericListItem import kotlinx.android.synthetic.main.controller_checkbox_item.* private object ControllerCheckBoxLayoutFactory : GenericLayoutFactory { override fun createLayout(parent : ViewGroup) : View = LayoutInflater.from(parent.context).inflate(R.layout.controller_checkbox_item, parent, false) } -class ControllerCheckBoxViewItem(var title : String, var summary : String, var checked : Boolean, private val onCheckedChange : (item : ControllerCheckBoxViewItem, position : Int) -> Unit) : GenericViewHolderBinder() { +class ControllerCheckBoxViewItem(var title : String, var summary : String, var checked : Boolean, private val onCheckedChange : (item : ControllerCheckBoxViewItem, position : Int) -> Unit) : GenericListItem() { override fun getLayoutFactory() : GenericLayoutFactory = ControllerCheckBoxLayoutFactory override fun bind(holder : GenericViewHolder, position : Int) { diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerGeneralViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerGeneralViewItem.kt index bb08a074..6ed9a6f2 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerGeneralViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerGeneralViewItem.kt @@ -6,6 +6,7 @@ package emu.skyline.adapter.controller import emu.skyline.R +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.GenericViewHolder import emu.skyline.input.GeneralType import emu.skyline.input.InputManager @@ -38,4 +39,8 @@ class ControllerGeneralViewItem(private val controllerId : Int, val type : Gener holder.itemView.setOnClickListener { onClick.invoke(this, position) } } + + override fun areItemsTheSame(other : GenericListItem) = other is ControllerGeneralViewItem && controllerId == other.controllerId + + override fun areContentsTheSame(other : GenericListItem) = other is ControllerGeneralViewItem && type == other.type } diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerStickViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerStickViewItem.kt index e7f02e1d..662f320c 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerStickViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerStickViewItem.kt @@ -6,6 +6,7 @@ package emu.skyline.adapter.controller import emu.skyline.R +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.GenericViewHolder import emu.skyline.input.AxisGuestEvent import emu.skyline.input.ButtonGuestEvent @@ -40,4 +41,8 @@ class ControllerStickViewItem(private val controllerId : Int, val stick : StickI holder.itemView.setOnClickListener { onClick.invoke(this, position) } } + + override fun areItemsTheSame(other : GenericListItem) = other is ControllerStickViewItem && controllerId == other.controllerId + + override fun areContentsTheSame(other : GenericListItem) = other is ControllerStickViewItem && stick == other.stick } diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerTypeViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerTypeViewItem.kt index fde16fbf..00d3bcaf 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerTypeViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerTypeViewItem.kt @@ -6,6 +6,7 @@ package emu.skyline.adapter.controller import emu.skyline.R +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.GenericViewHolder import emu.skyline.input.ControllerType @@ -23,4 +24,8 @@ class ControllerTypeViewItem(private val type : ControllerType, private val onCl holder.itemView.setOnClickListener { onClick.invoke(this, position) } } + + override fun areItemsTheSame(other : GenericListItem) = other is ControllerTypeViewItem + + override fun areContentsTheSame(other : GenericListItem) = other is ControllerTypeViewItem && type == other.type } diff --git a/app/src/main/java/emu/skyline/adapter/controller/ControllerViewItem.kt b/app/src/main/java/emu/skyline/adapter/controller/ControllerViewItem.kt index 212ba79f..72a3c3c9 100644 --- a/app/src/main/java/emu/skyline/adapter/controller/ControllerViewItem.kt +++ b/app/src/main/java/emu/skyline/adapter/controller/ControllerViewItem.kt @@ -11,15 +11,15 @@ import android.view.ViewGroup import androidx.core.view.isGone import emu.skyline.R import emu.skyline.adapter.GenericLayoutFactory +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.GenericViewHolder -import emu.skyline.adapter.GenericViewHolderBinder import kotlinx.android.synthetic.main.controller_item.* private object ControllerLayoutFactory : GenericLayoutFactory { override fun createLayout(parent : ViewGroup) : View = LayoutInflater.from(parent.context).inflate(R.layout.controller_item, parent, false) } -open class ControllerViewItem(var content : String = "", var subContent : String = "", private val onClick : (() -> Unit)? = null) : GenericViewHolderBinder() { +open class ControllerViewItem(var content : String = "", var subContent : String = "", private val onClick : (() -> Unit)? = null) : GenericListItem() { private var position = -1 override fun getLayoutFactory() : GenericLayoutFactory = ControllerLayoutFactory diff --git a/app/src/main/java/emu/skyline/data/AppItem.kt b/app/src/main/java/emu/skyline/data/AppItem.kt deleted file mode 100644 index 64c209f7..00000000 --- a/app/src/main/java/emu/skyline/data/AppItem.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-License-Identifier: MPL-2.0 - * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) - */ - -package emu.skyline.data - -import android.content.Context -import emu.skyline.R -import emu.skyline.loader.AppEntry -import emu.skyline.loader.LoaderResult - -/** - * This class is a wrapper around [AppEntry], it is used for passing around game metadata - */ -class AppItem(private val meta : AppEntry) : DataItem { - /** - * The icon of the application - */ - val icon get() = meta.icon - - /** - * The title of the application - */ - val title get() = meta.name - - /** - * The string used as the sub-title, we currently use the author - */ - val subTitle get() = meta.author - - /** - * The URI of the application's image file - */ - val uri get() = meta.uri - - /** - * The format of the application ROM as a string - */ - private val type get() = meta.format.name - - val loaderResult get() = meta.loaderResult - - fun loaderResultString(context : Context) = context.getString(when (meta.loaderResult) { - LoaderResult.Success -> R.string.metadata_missing - - LoaderResult.ParsingError -> R.string.invalid_file - - LoaderResult.MissingTitleKey -> R.string.missing_title_key - - LoaderResult.MissingHeaderKey, - LoaderResult.MissingTitleKek, - LoaderResult.MissingKeyArea -> R.string.incomplete_prod_keys - }) - - /** - * The name and author is used as the key - */ - fun key() = meta.name + if (meta.author != null) " ${meta.author}" else "" -} diff --git a/app/src/main/java/emu/skyline/data/DataItem.kt b/app/src/main/java/emu/skyline/data/DataItem.kt index a7cce749..c6aaef30 100644 --- a/app/src/main/java/emu/skyline/data/DataItem.kt +++ b/app/src/main/java/emu/skyline/data/DataItem.kt @@ -5,6 +5,56 @@ package emu.skyline.data +import android.content.Context +import emu.skyline.R +import emu.skyline.loader.AppEntry +import emu.skyline.loader.LoaderResult import java.io.Serializable -interface DataItem : Serializable +sealed class DataItem : Serializable + +class HeaderItem(val title : String) : DataItem() + +/** + * This class is a wrapper around [AppEntry], it is used for passing around game metadata + */ +data class AppItem(private val meta : AppEntry) : DataItem() { + /** + * The icon of the application + */ + val icon get() = meta.icon + + /** + * The title of the application + */ + val title get() = meta.name + + /** + * The string used as the sub-title, we currently use the author + */ + val subTitle get() = meta.author + + /** + * The URI of the application's image file + */ + val uri get() = meta.uri + + val loaderResult get() = meta.loaderResult + + fun loaderResultString(context : Context) = context.getString(when (meta.loaderResult) { + LoaderResult.Success -> R.string.metadata_missing + + LoaderResult.ParsingError -> R.string.invalid_file + + LoaderResult.MissingTitleKey -> R.string.missing_title_key + + LoaderResult.MissingHeaderKey, + LoaderResult.MissingTitleKek, + LoaderResult.MissingKeyArea -> R.string.incomplete_prod_keys + }) + + /** + * The name and author is used as the key + */ + fun key() = meta.name + if (meta.author != null) " ${meta.author}" else "" +} diff --git a/app/src/main/java/emu/skyline/data/HeaderItem.kt b/app/src/main/java/emu/skyline/data/HeaderItem.kt deleted file mode 100644 index 015989b0..00000000 --- a/app/src/main/java/emu/skyline/data/HeaderItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-License-Identifier: MPL-2.0 - * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) - */ - -package emu.skyline.data - -class HeaderItem(val title : String) : DataItem diff --git a/app/src/main/java/emu/skyline/input/ControllerActivity.kt b/app/src/main/java/emu/skyline/input/ControllerActivity.kt index f54ae287..f4b1e9e8 100644 --- a/app/src/main/java/emu/skyline/input/ControllerActivity.kt +++ b/app/src/main/java/emu/skyline/input/ControllerActivity.kt @@ -10,9 +10,11 @@ import android.os.Bundle import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import emu.skyline.R import emu.skyline.adapter.GenericAdapter +import emu.skyline.adapter.GenericListItem import emu.skyline.adapter.HeaderViewItem import emu.skyline.adapter.controller.* import emu.skyline.input.dialog.ButtonDialog @@ -53,99 +55,103 @@ class ControllerActivity : AppCompatActivity() { * This function updates the [adapter] based on information from [InputManager] */ private fun update() { - adapter.removeAllItems() + val items = mutableListOf() - val controller = InputManager.controllers[id]!! + try { + val controller = InputManager.controllers[id]!! - adapter.addItem(ControllerTypeViewItem(controller.type, onControllerTypeClick)) + items.add(ControllerTypeViewItem(controller.type, onControllerTypeClick)) - if (controller.type == ControllerType.None) - return + if (controller.type == ControllerType.None) + return - if (id == 0 && controller.type.firstController) { - adapter.addItem(HeaderViewItem(getString(R.string.osc))) + if (id == 0 && controller.type.firstController) { + items.add(HeaderViewItem(getString(R.string.osc))) - val oscSummary = { checked : Boolean -> getString(if (checked) R.string.osc_shown else R.string.osc_not_shown) } - adapter.addItem(ControllerCheckBoxViewItem(getString(R.string.osc_enable), oscSummary.invoke(settings.onScreenControl), settings.onScreenControl) { item, position -> - item.summary = oscSummary.invoke(item.checked) - settings.onScreenControl = item.checked - adapter.notifyItemChanged(position) - }) + val oscSummary = { checked : Boolean -> getString(if (checked) R.string.osc_shown else R.string.osc_not_shown) } + items.add(ControllerCheckBoxViewItem(getString(R.string.osc_enable), oscSummary.invoke(settings.onScreenControl), settings.onScreenControl) { item, position -> + item.summary = oscSummary.invoke(item.checked) + settings.onScreenControl = item.checked + adapter.notifyItemChanged(position) + }) - adapter.addItem(ControllerCheckBoxViewItem(getString(R.string.osc_recenter_sticks), "", settings.onScreenControlRecenterSticks) { item, position -> - settings.onScreenControlRecenterSticks = item.checked - adapter.notifyItemChanged(position) - }) + items.add(ControllerCheckBoxViewItem(getString(R.string.osc_recenter_sticks), "", settings.onScreenControlRecenterSticks) { item, position -> + settings.onScreenControlRecenterSticks = item.checked + adapter.notifyItemChanged(position) + }) - adapter.addItem(ControllerViewItem(content = getString(R.string.osc_edit), onClick = { - startActivity(Intent(this, OnScreenEditActivity::class.java)) - })) - } + items.add(ControllerViewItem(content = getString(R.string.osc_edit), onClick = { + startActivity(Intent(this, OnScreenEditActivity::class.java)) + })) + } - var wroteTitle = false + var wroteTitle = false - for (item in GeneralType.values()) { - if (item.compatibleControllers == null || item.compatibleControllers.contains(controller.type)) { + for (item in GeneralType.values()) { + if (item.compatibleControllers == null || item.compatibleControllers.contains(controller.type)) { + if (!wroteTitle) { + items.add(HeaderViewItem(getString(R.string.general))) + wroteTitle = true + } + + items.add(ControllerGeneralViewItem(id, item, onControllerGeneralClick)) + } + } + + wroteTitle = false + + for (stick in controller.type.sticks) { if (!wroteTitle) { - adapter.addItem(HeaderViewItem(getString(R.string.general))) + items.add(HeaderViewItem(getString(R.string.sticks))) wroteTitle = true } - adapter.addItem(ControllerGeneralViewItem(id, item, onControllerGeneralClick)) - } - } + val stickItem = ControllerStickViewItem(id, stick, onControllerStickClick) - wroteTitle = false - - for (stick in controller.type.sticks) { - if (!wroteTitle) { - adapter.addItem(HeaderViewItem(getString(R.string.sticks))) - wroteTitle = true + items.add(stickItem) + buttonMap[stick.button] = stickItem + axisMap[stick.xAxis] = stickItem + axisMap[stick.yAxis] = stickItem } - val stickItem = ControllerStickViewItem(id, stick, onControllerStickClick) + val dpadButtons = Pair(R.string.dpad, arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight)) + val faceButtons = Pair(R.string.face_buttons, arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y)) + val shoulderTriggerButtons = Pair(R.string.shoulder_trigger, arrayOf(ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR)) + val shoulderRailButtons = Pair(R.string.shoulder_rail, arrayOf(ButtonId.LeftSL, ButtonId.LeftSR, ButtonId.RightSL, ButtonId.RightSR)) - adapter.addItem(stickItem) - buttonMap[stick.button] = stickItem - axisMap[stick.xAxis] = stickItem - axisMap[stick.yAxis] = stickItem - } + val buttonArrays = arrayOf(dpadButtons, faceButtons, shoulderTriggerButtons, shoulderRailButtons) - val dpadButtons = Pair(R.string.dpad, arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight)) - val faceButtons = Pair(R.string.face_buttons, arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y)) - val shoulderTriggerButtons = Pair(R.string.shoulder_trigger, arrayOf(ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR)) - val shoulderRailButtons = Pair(R.string.shoulder_rail, arrayOf(ButtonId.LeftSL, ButtonId.LeftSR, ButtonId.RightSL, ButtonId.RightSR)) + for (buttonArray in buttonArrays) { + wroteTitle = false - val buttonArrays = arrayOf(dpadButtons, faceButtons, shoulderTriggerButtons, shoulderRailButtons) + for (button in controller.type.buttons.filter { it in buttonArray.second }) { + if (!wroteTitle) { + items.add(HeaderViewItem(getString(buttonArray.first))) + wroteTitle = true + } + + val buttonItem = ControllerButtonViewItem(id, button, onControllerButtonClick) + + items.add(buttonItem) + buttonMap[button] = buttonItem + } + } - for (buttonArray in buttonArrays) { wroteTitle = false - for (button in controller.type.buttons.filter { it in buttonArray.second }) { + for (button in controller.type.buttons.filterNot { item -> buttonArrays.any { item in it.second } }.plus(ButtonId.Menu)) { if (!wroteTitle) { - adapter.addItem(HeaderViewItem(getString(buttonArray.first))) + items.add(HeaderViewItem(getString(R.string.misc_buttons))) wroteTitle = true } val buttonItem = ControllerButtonViewItem(id, button, onControllerButtonClick) - adapter.addItem(buttonItem) + items.add(buttonItem) buttonMap[button] = buttonItem } - } - - wroteTitle = false - - for (button in controller.type.buttons.filterNot { item -> buttonArrays.any { item in it.second } }.plus(ButtonId.Menu)) { - if (!wroteTitle) { - adapter.addItem(HeaderViewItem(getString(R.string.misc_buttons))) - wroteTitle = true - } - - val buttonItem = ControllerButtonViewItem(id, button, onControllerButtonClick) - - adapter.addItem(buttonItem) - buttonMap[button] = buttonItem + } finally { + adapter.setItems(items) } } @@ -165,9 +171,18 @@ class ControllerActivity : AppCompatActivity() { setSupportActionBar(toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - controller_list.layoutManager = LinearLayoutManager(this) + val layoutManager = LinearLayoutManager(this) + controller_list.layoutManager = layoutManager controller_list.adapter = adapter + controller_list.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView : RecyclerView, dx : Int, dy : Int) { + super.onScrolled(recyclerView, dx, dy) + + if (layoutManager.findLastCompletelyVisibleItemPosition() == adapter.itemCount - 1) app_bar_layout.setExpanded(false) + } + }) + update() } diff --git a/app/src/main/java/emu/skyline/input/onscreen/OnScreenEditActivity.kt b/app/src/main/java/emu/skyline/input/onscreen/OnScreenEditActivity.kt index b1fe6274..5d07c9d4 100644 --- a/app/src/main/java/emu/skyline/input/onscreen/OnScreenEditActivity.kt +++ b/app/src/main/java/emu/skyline/input/onscreen/OnScreenEditActivity.kt @@ -5,9 +5,9 @@ package emu.skyline.input.onscreen -import android.graphics.Color import android.os.Build import android.os.Bundle +import android.view.LayoutInflater import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController @@ -17,7 +17,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton import emu.skyline.R import emu.skyline.utils.Settings -import kotlinx.android.synthetic.main.main_activity.fab_parent import kotlinx.android.synthetic.main.on_screen_edit_activity.* class OnScreenEditActivity : AppCompatActivity() { @@ -87,10 +86,8 @@ class OnScreenEditActivity : AppCompatActivity() { on_screen_controller_view.recenterSticks = Settings(this).onScreenControlRecenterSticks actions.forEach { pair -> - fab_parent.addView(FloatingActionButton(this).apply { - size = FloatingActionButton.SIZE_MINI - setColorFilter(Color.WHITE) - setImageDrawable(ContextCompat.getDrawable(context, pair.first)) + fab_parent.addView(LayoutInflater.from(this).inflate(R.layout.on_screen_edit_mini_fab, fab_parent, false).apply { + (this as FloatingActionButton).setImageDrawable(ContextCompat.getDrawable(context, pair.first)) setOnClickListener { pair.second.invoke() } fabMapping[pair.first] = this }) diff --git a/app/src/main/java/emu/skyline/loader/RomFile.kt b/app/src/main/java/emu/skyline/loader/RomFile.kt index 74daf2e3..2fbf4c07 100644 --- a/app/src/main/java/emu/skyline/loader/RomFile.kt +++ b/app/src/main/java/emu/skyline/loader/RomFile.kt @@ -63,7 +63,7 @@ enum class LoaderResult(val value : Int) { /** * This class is used to hold an application's metadata in a serializable way */ -class AppEntry(var name : String, var author : String?, var icon : Bitmap?, var format : RomFormat, var uri : Uri, var loaderResult : LoaderResult) : Serializable { +data class AppEntry(var name : String, var author : String?, var icon : Bitmap?, var format : RomFormat, var uri : Uri, var loaderResult : LoaderResult) : Serializable { constructor(context : Context, format : RomFormat, uri : Uri, loaderResult : LoaderResult) : this(context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> val nameIndex : Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) cursor.moveToFirst() diff --git a/app/src/main/java/emu/skyline/preference/DocumentActivity.kt b/app/src/main/java/emu/skyline/preference/DocumentActivity.kt index a8390986..cbaf2680 100644 --- a/app/src/main/java/emu/skyline/preference/DocumentActivity.kt +++ b/app/src/main/java/emu/skyline/preference/DocumentActivity.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.preference.PreferenceManager +import emu.skyline.utils.Settings /** * This activity is used to launch a document picker and saves the result to preferences @@ -45,9 +46,9 @@ abstract class DocumentActivity : AppCompatActivity() { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + Settings(this).refreshRequired = true PreferenceManager.getDefaultSharedPreferences(this).edit() .putString(keyName, uri.toString()) - .putBoolean("refresh_required", true) .apply() } finish() diff --git a/app/src/main/java/emu/skyline/views/CustomLinearLayout.kt b/app/src/main/java/emu/skyline/views/CustomLinearLayout.kt deleted file mode 100644 index 2cdcd705..00000000 --- a/app/src/main/java/emu/skyline/views/CustomLinearLayout.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-License-Identifier: MPL-2.0 - * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) - */ - -package emu.skyline.views - -import android.content.Context -import android.graphics.Rect -import android.util.AttributeSet -import android.view.View -import android.widget.LinearLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.snackbar.Snackbar.SnackbarLayout - -/** - * Custom linear layout with support for [CoordinatorLayout] to move children, when [com.google.android.material.snackbar.Snackbar] shows up - */ -class CustomLinearLayout : LinearLayout, CoordinatorLayout.AttachedBehavior { - constructor(context : Context) : this(context, null) - constructor(context : Context, attrs : AttributeSet?) : this(context, attrs, 0) - constructor(context : Context, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) - - override fun getBehavior() : CoordinatorLayout.Behavior = MoveUpwardBehavior() - - override fun requestFocus(direction: Int, previouslyFocusedRect: Rect): Boolean = getChildAt(if (direction == View.FOCUS_UP) childCount - 1 else 0 )?.requestFocus() ?: false - - /** - * Defines behaviour when [com.google.android.material.snackbar.Snackbar] is shown - * Simply sets an offset to y translation to move children out of the way - */ - class MoveUpwardBehavior : CoordinatorLayout.Behavior() { - override fun layoutDependsOn(parent : CoordinatorLayout, child : CustomLinearLayout, dependency : View) : Boolean = dependency is SnackbarLayout - - override fun onDependentViewChanged(parent : CoordinatorLayout, child : CustomLinearLayout, dependency : View) : Boolean { - child.translationY = (0f).coerceAtMost(dependency.translationY - dependency.height) - return true - } - - override fun onDependentViewRemoved(parent : CoordinatorLayout, child : CustomLinearLayout, dependency : View) { - child.translationY = 0f - } - } -} diff --git a/app/src/main/java/emu/skyline/views/SearchBarView.kt b/app/src/main/java/emu/skyline/views/SearchBarView.kt new file mode 100644 index 00000000..cf5abe3b --- /dev/null +++ b/app/src/main/java/emu/skyline/views/SearchBarView.kt @@ -0,0 +1,104 @@ +package emu.skyline.views + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.inputmethod.InputMethodManager +import androidx.core.view.MarginLayoutParamsCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.google.android.material.card.MaterialCardView +import emu.skyline.R +import kotlinx.android.synthetic.main.view_search_bar.view.* +import kotlin.math.roundToInt + +class SearchBarView @JvmOverloads constructor(context : Context, attrs : AttributeSet? = null, defStyleAttr : Int = com.google.android.material.R.attr.materialCardViewStyle) : MaterialCardView(context, attrs, defStyleAttr) { + init { + LayoutInflater.from(context).inflate(R.layout.view_search_bar, this) + useCompatPadding = true + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + val margin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f, context.resources.displayMetrics).roundToInt() + MarginLayoutParamsCompat.setMarginStart(layoutParams as MarginLayoutParams?, margin) + MarginLayoutParamsCompat.setMarginEnd(layoutParams as MarginLayoutParams?, margin) + + radius = margin / 2f + cardElevation = radius / 2f + } + + fun setRefreshIconListener(listener : OnClickListener) = refresh_icon.setOnClickListener(listener) + fun setLogIconListener(listener : OnClickListener) = log_icon.setOnClickListener(listener) + fun setSettingsIconListener(listener : OnClickListener) = settings_icon.setOnClickListener(listener) + + var refreshIconVisible = false + set(visible) { + field = visible + refresh_icon.apply { + if (visible != isVisible) { + refresh_icon.alpha = if (visible) 0f else 1f + animate().alpha(if (visible) 1f else 0f).withStartAction { isVisible = true }.withEndAction { isInvisible = !visible }.apply { duration = 500 }.start() + } + } + } + + var text : CharSequence + get() = search_field.text + set(value) = search_field.setText(value) + + fun startTitleAnimation() { + motion_layout.progress = 0f + motion_layout.transitionToEnd() + search_field.apply { + setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + this@SearchBarView.motion_layout.progress = 1f + context.getSystemService(InputMethodManager::class.java).showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) + onFocusChangeListener = null + } + } + } + } + + fun animateRefreshIcon() { + refresh_icon.animate().rotationBy(-180f) + } + + inline fun addTextChangedListener( + crossinline beforeTextChanged : ( + text : CharSequence?, + start : Int, + count : Int, + after : Int + ) -> Unit = { _, _, _, _ -> }, + crossinline onTextChanged : ( + text : CharSequence?, + start : Int, + before : Int, + count : Int + ) -> Unit = { _, _, _, _ -> }, + crossinline afterTextChanged : (text : Editable?) -> Unit = {} + ) : TextWatcher { + val textWatcher = object : TextWatcher { + override fun afterTextChanged(s : Editable?) { + afterTextChanged.invoke(s) + } + + override fun beforeTextChanged(text : CharSequence?, start : Int, count : Int, after : Int) { + beforeTextChanged.invoke(text, start, count, after) + } + + override fun onTextChanged(text : CharSequence?, start : Int, before : Int, count : Int) { + onTextChanged.invoke(text, start, before, count) + } + } + search_field.addTextChangedListener(textWatcher) + + return textWatcher + } +} diff --git a/app/src/main/res/layout/app_dialog.xml b/app/src/main/res/layout/app_dialog.xml index 3fbc1225..59cccc88 100644 --- a/app/src/main/res/layout/app_dialog.xml +++ b/app/src/main/res/layout/app_dialog.xml @@ -56,7 +56,9 @@ android:layout_marginEnd="6dp" android:focusedByDefault="true" android:text="@string/play" - app:icon="@drawable/ic_play" /> + android:textColor="?attr/colorAccent" + app:icon="@drawable/ic_play" + app:iconTint="?attr/colorAccent" />