mirror of https://git.suyu.dev/suyu/suyu
android: Remove game database
The content provider + database solution was excessive and is now replaced with the simple file checks from before but turned into an array list held within a viewmodel.
This commit is contained in:
parent
4816d5158e
commit
9d7a60346f
|
@ -140,10 +140,6 @@ dependencies {
|
||||||
implementation "io.coil-kt:coil:2.2.2"
|
implementation "io.coil-kt:coil:2.2.2"
|
||||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||||
implementation 'androidx.window:window:1.0.0'
|
implementation 'androidx.window:window:1.0.0'
|
||||||
|
|
||||||
// Allows FRP-style asynchronous operations in Android.
|
|
||||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
|
||||||
implementation 'com.nononsenseapps:filepicker:4.2.1'
|
|
||||||
implementation 'org.ini4j:ini4j:0.5.4'
|
implementation 'org.ini4j:ini4j:0.5.4'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||||
|
|
|
@ -65,23 +65,6 @@
|
||||||
|
|
||||||
<service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/>
|
<service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/>
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="org.yuzu.yuzu_emu.model.GameProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false">
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.filesprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/nnf_provider_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".features.DocumentProvider"
|
android:name=".features.DocumentProvider"
|
||||||
android:authorities="${applicationId}.user"
|
android:authorities="${applicationId}.user"
|
||||||
|
|
|
@ -7,7 +7,6 @@ import android.app.Application
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.yuzu.yuzu_emu.model.GameDatabase
|
|
||||||
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
||||||
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
|
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
|
||||||
|
@ -45,12 +44,9 @@ class YuzuApplication : Application() {
|
||||||
|
|
||||||
// TODO(bunnei): Disable notifications until we support app suspension.
|
// TODO(bunnei): Disable notifications until we support app suspension.
|
||||||
//createNotificationChannel();
|
//createNotificationChannel();
|
||||||
databaseHelper = GameDatabase(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var databaseHelper: GameDatabase? = null
|
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
var documentsTree: DocumentsTree? = null
|
var documentsTree: DocumentsTree? = null
|
||||||
lateinit var application: YuzuApplication
|
lateinit var application: YuzuApplication
|
||||||
|
|
|
@ -3,11 +3,8 @@
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.adapters
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.database.DataSetObserver
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -16,7 +13,6 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import coil.load
|
import coil.load
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -25,31 +21,16 @@ import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.databinding.CardGameBinding
|
import org.yuzu.yuzu_emu.databinding.CardGameBinding
|
||||||
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
import org.yuzu.yuzu_emu.model.Game
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
import org.yuzu.yuzu_emu.model.GameDatabase
|
import kotlin.collections.ArrayList
|
||||||
import org.yuzu.yuzu_emu.utils.Log
|
|
||||||
import org.yuzu.yuzu_emu.viewholders.GameViewHolder
|
|
||||||
import java.util.*
|
|
||||||
import java.util.stream.Stream
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This adapter gets its information from a database Cursor. This fact, paired with the usage of
|
* This adapter gets its information from a database Cursor. This fact, paired with the usage of
|
||||||
* ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
|
* ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
|
||||||
* large dataset.
|
* large dataset.
|
||||||
*/
|
*/
|
||||||
class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapter<GameViewHolder>(),
|
class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<Game>) :
|
||||||
|
RecyclerView.Adapter<GameAdapter.GameViewHolder>(),
|
||||||
View.OnClickListener {
|
View.OnClickListener {
|
||||||
private var cursor: Cursor? = null
|
|
||||||
private val observer: GameDataSetObserver?
|
|
||||||
private var isDatasetValid = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the adapter's observer, which watches for changes to the dataset. The adapter will
|
|
||||||
* display no data until a Cursor is supplied by a CursorLoader.
|
|
||||||
*/
|
|
||||||
init {
|
|
||||||
observer = GameDataSetObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
|
||||||
// Create a new view.
|
// Create a new view.
|
||||||
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
|
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
|
||||||
|
@ -60,114 +41,11 @@ class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapte
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
|
||||||
if (isDatasetValid) {
|
holder.bind(games[position])
|
||||||
if (cursor!!.moveToPosition(position)) {
|
|
||||||
// TODO These shouldn't be necessary once the move to a DB-based model is complete.
|
|
||||||
val game = Game(
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_TITLE),
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_DESCRIPTION),
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_REGIONS),
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_PATH),
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_GAME_ID),
|
|
||||||
cursor!!.getString(GameDatabase.GAME_COLUMN_CAPTION)
|
|
||||||
)
|
|
||||||
holder.game = game
|
|
||||||
|
|
||||||
holder.binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
|
|
||||||
activity.lifecycleScope.launch {
|
|
||||||
val bitmap = decodeGameIcon(game.path)
|
|
||||||
holder.binding.imageGameScreen.load(bitmap) {
|
|
||||||
error(R.drawable.no_icon)
|
|
||||||
crossfade(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
|
|
||||||
holder.binding.textGameCaption.text = game.company
|
|
||||||
|
|
||||||
if (game.company.isEmpty()) {
|
|
||||||
holder.binding.textGameCaption.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
val backgroundColorId =
|
|
||||||
if (isValidGame(holder.game.path)) R.attr.colorSurface else R.attr.colorErrorContainer
|
|
||||||
val itemView = holder.itemView
|
|
||||||
itemView.setBackgroundColor(
|
|
||||||
MaterialColors.getColor(
|
|
||||||
itemView,
|
|
||||||
backgroundColorId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Log.error("[GameAdapter] Can't bind view; Cursor is not valid.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.error("[GameAdapter] Can't bind view; dataset is not valid.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
if (isDatasetValid && cursor != null) {
|
return games.size
|
||||||
return cursor!!.count
|
|
||||||
}
|
|
||||||
Log.error("[GameAdapter] Dataset is not valid.")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the contents of the _id column for a given row.
|
|
||||||
*
|
|
||||||
* @param position The row for which Android wants an ID.
|
|
||||||
* @return A valid ID from the database, or 0 if not available.
|
|
||||||
*/
|
|
||||||
override fun getItemId(position: Int): Long {
|
|
||||||
if (isDatasetValid && cursor != null) {
|
|
||||||
if (cursor!!.moveToPosition(position)) {
|
|
||||||
return cursor!!.getLong(GameDatabase.COLUMN_DB_ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.error("[GameAdapter] Dataset is not valid.")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tell Android whether or not each item in the dataset has a stable identifier.
|
|
||||||
* Which it does, because it's a database, so always tell Android 'true'.
|
|
||||||
*
|
|
||||||
* @param hasStableIds ignored.
|
|
||||||
*/
|
|
||||||
override fun setHasStableIds(hasStableIds: Boolean) {
|
|
||||||
super.setHasStableIds(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a load is finished, call this to replace the existing data with the newly-loaded
|
|
||||||
* data.
|
|
||||||
*
|
|
||||||
* @param cursor The newly-loaded Cursor.
|
|
||||||
*/
|
|
||||||
fun swapCursor(cursor: Cursor) {
|
|
||||||
// Sanity check.
|
|
||||||
if (cursor === this.cursor) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before getting rid of the old cursor, disassociate it from the Observer.
|
|
||||||
val oldCursor = this.cursor
|
|
||||||
if (oldCursor != null && observer != null) {
|
|
||||||
oldCursor.unregisterDataSetObserver(observer)
|
|
||||||
}
|
|
||||||
this.cursor = cursor
|
|
||||||
isDatasetValid = if (this.cursor != null) {
|
|
||||||
// Attempt to associate the new Cursor with the Observer.
|
|
||||||
if (observer != null) {
|
|
||||||
this.cursor!!.registerDataSetObserver(observer)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -180,11 +58,38 @@ class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapte
|
||||||
EmulationActivity.launch((view.context as AppCompatActivity), holder.game)
|
EmulationActivity.launch((view.context as AppCompatActivity), holder.game)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidGame(path: String): Boolean {
|
inner class GameViewHolder(val binding: CardGameBinding) :
|
||||||
return Stream.of(".rar", ".zip", ".7z", ".torrent", ".tar", ".gz")
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
.noneMatch { suffix: String? ->
|
lateinit var game: Game
|
||||||
path.lowercase(Locale.getDefault()).endsWith(suffix!!)
|
|
||||||
|
init {
|
||||||
|
itemView.tag = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(game: Game) {
|
||||||
|
this.game = game
|
||||||
|
|
||||||
|
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
activity.lifecycleScope.launch {
|
||||||
|
val bitmap = decodeGameIcon(game.path)
|
||||||
|
binding.imageGameScreen.load(bitmap) {
|
||||||
|
error(R.drawable.no_icon)
|
||||||
|
crossfade(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
|
||||||
|
binding.textGameCaption.text = game.company
|
||||||
|
|
||||||
|
if (game.company.isEmpty()) {
|
||||||
|
binding.textGameCaption.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun swapData(games: ArrayList<Game>) {
|
||||||
|
this.games = games
|
||||||
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeGameIcon(uri: String): Bitmap? {
|
private fun decodeGameIcon(uri: String): Bitmap? {
|
||||||
|
@ -196,18 +101,4 @@ class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapte
|
||||||
BitmapFactory.Options()
|
BitmapFactory.Options()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class GameDataSetObserver : DataSetObserver() {
|
|
||||||
override fun onChanged() {
|
|
||||||
super.onChanged()
|
|
||||||
isDatasetValid = true
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInvalidated() {
|
|
||||||
super.onInvalidated()
|
|
||||||
isDatasetValid = false
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,8 @@
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.model
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.util.HashSet
|
import java.util.HashSet
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
@ -23,40 +20,5 @@ class Game(
|
||||||
val extensions: Set<String> = HashSet(
|
val extensions: Set<String> = HashSet(
|
||||||
listOf(".xci", ".nsp", ".nca", ".nro")
|
listOf(".xci", ".nsp", ".nca", ".nro")
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun asContentValues(
|
|
||||||
title: String?,
|
|
||||||
description: String?,
|
|
||||||
regions: String?,
|
|
||||||
path: String?,
|
|
||||||
gameId: String,
|
|
||||||
company: String?
|
|
||||||
): ContentValues {
|
|
||||||
var realGameId = gameId
|
|
||||||
val values = ContentValues()
|
|
||||||
if (realGameId.isEmpty()) {
|
|
||||||
// Homebrew, etc. may not have a game ID, use filename as a unique identifier
|
|
||||||
realGameId = Paths.get(path).fileName.toString()
|
|
||||||
}
|
|
||||||
values.put(GameDatabase.KEY_GAME_TITLE, title)
|
|
||||||
values.put(GameDatabase.KEY_GAME_DESCRIPTION, description)
|
|
||||||
values.put(GameDatabase.KEY_GAME_REGIONS, regions)
|
|
||||||
values.put(GameDatabase.KEY_GAME_PATH, path)
|
|
||||||
values.put(GameDatabase.KEY_GAME_ID, realGameId)
|
|
||||||
values.put(GameDatabase.KEY_GAME_COMPANY, company)
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fromCursor(cursor: Cursor): Game {
|
|
||||||
return Game(
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_TITLE),
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION),
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_REGIONS),
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_PATH),
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
|
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_CAPTION)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,263 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.model
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
|
||||||
import android.net.Uri
|
|
||||||
import org.yuzu.yuzu_emu.NativeLibrary
|
|
||||||
import org.yuzu.yuzu_emu.utils.FileUtil
|
|
||||||
import org.yuzu.yuzu_emu.utils.Log
|
|
||||||
import rx.Observable
|
|
||||||
import rx.Subscriber
|
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper class that provides several utilities simplifying interaction with
|
|
||||||
* the SQLite database.
|
|
||||||
*/
|
|
||||||
class GameDatabase(private val context: Context) :
|
|
||||||
SQLiteOpenHelper(context, "games.db", null, DB_VERSION) {
|
|
||||||
override fun onCreate(database: SQLiteDatabase) {
|
|
||||||
Log.debug("[GameDatabase] GameDatabase - Creating database...")
|
|
||||||
execSqlAndLog(database, SQL_CREATE_GAMES)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_FOLDERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDowngrade(database: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
|
||||||
Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases..")
|
|
||||||
execSqlAndLog(database, SQL_DELETE_FOLDERS)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_FOLDERS)
|
|
||||||
execSqlAndLog(database, SQL_DELETE_GAMES)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_GAMES)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUpgrade(database: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
|
||||||
Log.info(
|
|
||||||
"[GameDatabase] Upgrading database from schema version $oldVersion to $newVersion"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete all the games
|
|
||||||
execSqlAndLog(database, SQL_DELETE_GAMES)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_GAMES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetDatabase(database: SQLiteDatabase) {
|
|
||||||
execSqlAndLog(database, SQL_DELETE_FOLDERS)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_FOLDERS)
|
|
||||||
execSqlAndLog(database, SQL_DELETE_GAMES)
|
|
||||||
execSqlAndLog(database, SQL_CREATE_GAMES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scanLibrary(database: SQLiteDatabase) {
|
|
||||||
// Before scanning known folders, go through the game table and remove any entries for which the file itself is missing.
|
|
||||||
val fileCursor = database.query(
|
|
||||||
TABLE_NAME_GAMES,
|
|
||||||
null, // Get all columns.
|
|
||||||
null, // Get all rows.
|
|
||||||
null,
|
|
||||||
null, // No grouping.
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
) // Order of games is irrelevant.
|
|
||||||
|
|
||||||
// Possibly overly defensive, but ensures that moveToNext() does not skip a row.
|
|
||||||
fileCursor.moveToPosition(-1)
|
|
||||||
while (fileCursor.moveToNext()) {
|
|
||||||
val gamePath = fileCursor.getString(GAME_COLUMN_PATH)
|
|
||||||
val game = File(gamePath)
|
|
||||||
if (!game.exists()) {
|
|
||||||
database.delete(
|
|
||||||
TABLE_NAME_GAMES,
|
|
||||||
"$KEY_DB_ID = ?",
|
|
||||||
arrayOf(fileCursor.getLong(COLUMN_DB_ID).toString())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a cursor listing all the folders the user has added to the library.
|
|
||||||
val folderCursor = database.query(
|
|
||||||
TABLE_NAME_FOLDERS,
|
|
||||||
null, // Get all columns.
|
|
||||||
null, // Get all rows.
|
|
||||||
null,
|
|
||||||
null, // No grouping.
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
) // Order of folders is irrelevant.
|
|
||||||
|
|
||||||
|
|
||||||
// Possibly overly defensive, but ensures that moveToNext() does not skip a row.
|
|
||||||
folderCursor.moveToPosition(-1)
|
|
||||||
|
|
||||||
// Iterate through all results of the DB query (i.e. all folders in the library.)
|
|
||||||
while (folderCursor.moveToNext()) {
|
|
||||||
val folderPath = folderCursor.getString(FOLDER_COLUMN_PATH)
|
|
||||||
val folderUri = Uri.parse(folderPath)
|
|
||||||
// If the folder is empty because it no longer exists, remove it from the library.
|
|
||||||
if (FileUtil.listFiles(context, folderUri).isEmpty()) {
|
|
||||||
Log.error(
|
|
||||||
"[GameDatabase] Folder no longer exists. Removing from the library: $folderPath"
|
|
||||||
)
|
|
||||||
database.delete(
|
|
||||||
TABLE_NAME_FOLDERS,
|
|
||||||
"$KEY_DB_ID = ?",
|
|
||||||
arrayOf(folderCursor.getLong(COLUMN_DB_ID).toString())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addGamesRecursive(database, folderUri, Game.extensions, 3)
|
|
||||||
}
|
|
||||||
fileCursor.close()
|
|
||||||
folderCursor.close()
|
|
||||||
database.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addGamesRecursive(
|
|
||||||
database: SQLiteDatabase,
|
|
||||||
parent: Uri,
|
|
||||||
allowedExtensions: Set<String>,
|
|
||||||
depth: Int
|
|
||||||
) {
|
|
||||||
if (depth <= 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
|
||||||
NativeLibrary.ReloadKeys()
|
|
||||||
val children = FileUtil.listFiles(context, parent)
|
|
||||||
for (file in children) {
|
|
||||||
if (file.isDirectory) {
|
|
||||||
addGamesRecursive(database, file.uri, Game.extensions, depth - 1)
|
|
||||||
} else {
|
|
||||||
val filename = file.uri.toString()
|
|
||||||
val extensionStart = filename.lastIndexOf('.')
|
|
||||||
if (extensionStart > 0) {
|
|
||||||
val fileExtension = filename.substring(extensionStart)
|
|
||||||
|
|
||||||
// Check that the file has an extension we care about before trying to read out of it.
|
|
||||||
if (allowedExtensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
|
|
||||||
attemptToAddGame(database, filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pass the result cursor to the consumer.
|
|
||||||
|
|
||||||
// Tell the consumer we're done; it will unsubscribe implicitly.
|
|
||||||
val games: Observable<Cursor?>
|
|
||||||
get() = Observable.create { subscriber: Subscriber<in Cursor?> ->
|
|
||||||
Log.info("[GameDatabase] Reading games list...")
|
|
||||||
val database = readableDatabase
|
|
||||||
val resultCursor = database.query(
|
|
||||||
TABLE_NAME_GAMES,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
"$KEY_GAME_TITLE ASC"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pass the result cursor to the consumer.
|
|
||||||
subscriber.onNext(resultCursor)
|
|
||||||
|
|
||||||
// Tell the consumer we're done; it will unsubscribe implicitly.
|
|
||||||
subscriber.onCompleted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun execSqlAndLog(database: SQLiteDatabase, sql: String) {
|
|
||||||
Log.verbose("[GameDatabase] Executing SQL: $sql")
|
|
||||||
database.execSQL(sql)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val COLUMN_DB_ID = 0
|
|
||||||
const val GAME_COLUMN_PATH = 1
|
|
||||||
const val GAME_COLUMN_TITLE = 2
|
|
||||||
const val GAME_COLUMN_DESCRIPTION = 3
|
|
||||||
const val GAME_COLUMN_REGIONS = 4
|
|
||||||
const val GAME_COLUMN_GAME_ID = 5
|
|
||||||
const val GAME_COLUMN_CAPTION = 6
|
|
||||||
const val FOLDER_COLUMN_PATH = 1
|
|
||||||
const val KEY_DB_ID = "_id"
|
|
||||||
const val KEY_GAME_PATH = "path"
|
|
||||||
const val KEY_GAME_TITLE = "title"
|
|
||||||
const val KEY_GAME_DESCRIPTION = "description"
|
|
||||||
const val KEY_GAME_REGIONS = "regions"
|
|
||||||
const val KEY_GAME_ID = "game_id"
|
|
||||||
const val KEY_GAME_COMPANY = "company"
|
|
||||||
const val KEY_FOLDER_PATH = "path"
|
|
||||||
const val TABLE_NAME_FOLDERS = "folders"
|
|
||||||
const val TABLE_NAME_GAMES = "games"
|
|
||||||
private const val DB_VERSION = 2
|
|
||||||
private const val TYPE_PRIMARY = " INTEGER PRIMARY KEY"
|
|
||||||
private const val TYPE_INTEGER = " INTEGER"
|
|
||||||
private const val TYPE_STRING = " TEXT"
|
|
||||||
private const val CONSTRAINT_UNIQUE = " UNIQUE"
|
|
||||||
private const val SEPARATOR = ", "
|
|
||||||
private const val SQL_CREATE_GAMES = ("CREATE TABLE " + TABLE_NAME_GAMES + "("
|
|
||||||
+ KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
|
|
||||||
+ KEY_GAME_PATH + TYPE_STRING + SEPARATOR
|
|
||||||
+ KEY_GAME_TITLE + TYPE_STRING + SEPARATOR
|
|
||||||
+ KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR
|
|
||||||
+ KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR
|
|
||||||
+ KEY_GAME_ID + TYPE_STRING + SEPARATOR
|
|
||||||
+ KEY_GAME_COMPANY + TYPE_STRING + ")")
|
|
||||||
private const val SQL_CREATE_FOLDERS = ("CREATE TABLE " + TABLE_NAME_FOLDERS + "("
|
|
||||||
+ KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
|
|
||||||
+ KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")")
|
|
||||||
private const val SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS $TABLE_NAME_FOLDERS"
|
|
||||||
private const val SQL_DELETE_GAMES = "DROP TABLE IF EXISTS $TABLE_NAME_GAMES"
|
|
||||||
private fun attemptToAddGame(database: SQLiteDatabase, filePath: String) {
|
|
||||||
var name = NativeLibrary.GetTitle(filePath)
|
|
||||||
|
|
||||||
// If the game's title field is empty, use the filename.
|
|
||||||
if (name.isEmpty()) {
|
|
||||||
name = filePath.substring(filePath.lastIndexOf("/") + 1)
|
|
||||||
}
|
|
||||||
var gameId = NativeLibrary.GetGameId(filePath)
|
|
||||||
|
|
||||||
// If the game's ID field is empty, use the filename without extension.
|
|
||||||
if (gameId.isEmpty()) {
|
|
||||||
gameId = filePath.substring(
|
|
||||||
filePath.lastIndexOf("/") + 1,
|
|
||||||
filePath.lastIndexOf(".")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val game = Game.asContentValues(
|
|
||||||
name,
|
|
||||||
NativeLibrary.GetDescription(filePath).replace("\n", " "),
|
|
||||||
NativeLibrary.GetRegions(filePath),
|
|
||||||
filePath,
|
|
||||||
gameId,
|
|
||||||
NativeLibrary.GetCompany(filePath)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Try to update an existing game first.
|
|
||||||
val rowsMatched = database.update(
|
|
||||||
TABLE_NAME_GAMES, // Which table to update.
|
|
||||||
game, // The values to fill the row with.
|
|
||||||
"$KEY_GAME_ID = ?", arrayOf(
|
|
||||||
game.getAsString(
|
|
||||||
KEY_GAME_ID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// The ? in WHERE clause is replaced with this,
|
|
||||||
// which is provided as an array because there
|
|
||||||
// could potentially be more than one argument.
|
|
||||||
|
|
||||||
// If update fails, insert a new game instead.
|
|
||||||
if (rowsMatched == 0) {
|
|
||||||
Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE))
|
|
||||||
database.insert(TABLE_NAME_GAMES, null, game)
|
|
||||||
} else {
|
|
||||||
Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.model
|
|
||||||
|
|
||||||
import android.content.ContentProvider
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.net.Uri
|
|
||||||
import org.yuzu.yuzu_emu.BuildConfig
|
|
||||||
import org.yuzu.yuzu_emu.utils.Log
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides an interface allowing Activities to interact with the SQLite database.
|
|
||||||
* CRUD methods in this class can be called by Activities using getContentResolver().
|
|
||||||
*/
|
|
||||||
class GameProvider : ContentProvider() {
|
|
||||||
private var mDbHelper: GameDatabase? = null
|
|
||||||
override fun onCreate(): Boolean {
|
|
||||||
Log.info("[GameProvider] Creating Content Provider...")
|
|
||||||
mDbHelper = GameDatabase(context!!)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun query(
|
|
||||||
uri: Uri,
|
|
||||||
projection: Array<String>?,
|
|
||||||
selection: String?,
|
|
||||||
selectionArgs: Array<String>?,
|
|
||||||
sortOrder: String?
|
|
||||||
): Cursor? {
|
|
||||||
Log.info("[GameProvider] Querying URI: $uri")
|
|
||||||
val db = mDbHelper!!.readableDatabase
|
|
||||||
val table = uri.lastPathSegment
|
|
||||||
if (table == null) {
|
|
||||||
Log.error("[GameProvider] Badly formatted URI: $uri")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder)
|
|
||||||
cursor.setNotificationUri(context!!.contentResolver, uri)
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getType(uri: Uri): String? {
|
|
||||||
Log.verbose("[GameProvider] Getting MIME type for URI: $uri")
|
|
||||||
val lastSegment = uri.lastPathSegment
|
|
||||||
if (lastSegment == null) {
|
|
||||||
Log.error("[GameProvider] Badly formatted URI: $uri")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (lastSegment == GameDatabase.TABLE_NAME_FOLDERS) {
|
|
||||||
return MIME_TYPE_FOLDER
|
|
||||||
} else if (lastSegment == GameDatabase.TABLE_NAME_GAMES) {
|
|
||||||
return MIME_TYPE_GAME
|
|
||||||
}
|
|
||||||
Log.error("[GameProvider] Unknown MIME type for URI: $uri")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
|
||||||
var realUri = uri
|
|
||||||
Log.info("[GameProvider] Inserting row at URI: $realUri")
|
|
||||||
val database = mDbHelper!!.writableDatabase
|
|
||||||
val table = realUri.lastPathSegment
|
|
||||||
if (table != null) {
|
|
||||||
if (table == RESET_LIBRARY) {
|
|
||||||
mDbHelper!!.resetDatabase(database)
|
|
||||||
return realUri
|
|
||||||
}
|
|
||||||
if (table == REFRESH_LIBRARY) {
|
|
||||||
Log.info(
|
|
||||||
"[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents..."
|
|
||||||
)
|
|
||||||
mDbHelper!!.scanLibrary(database)
|
|
||||||
return realUri
|
|
||||||
}
|
|
||||||
val id =
|
|
||||||
database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE)
|
|
||||||
|
|
||||||
// If insertion was successful...
|
|
||||||
if (id > 0) {
|
|
||||||
// If we just added a folder, add its contents to the game list.
|
|
||||||
if (table == GameDatabase.TABLE_NAME_FOLDERS) {
|
|
||||||
mDbHelper!!.scanLibrary(database)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify the UI that its contents should be refreshed.
|
|
||||||
context!!.contentResolver.notifyChange(realUri, null)
|
|
||||||
realUri = Uri.withAppendedPath(realUri, id.toString())
|
|
||||||
} else {
|
|
||||||
Log.error("[GameProvider] Row already exists: $realUri id: $id")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.error("[GameProvider] Badly formatted URI: $realUri")
|
|
||||||
}
|
|
||||||
database.close()
|
|
||||||
return realUri
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
|
||||||
Log.error("[GameProvider] Delete operations unsupported. URI: $uri")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun update(
|
|
||||||
uri: Uri, values: ContentValues?, selection: String?,
|
|
||||||
selectionArgs: Array<String>?
|
|
||||||
): Int {
|
|
||||||
Log.error("[GameProvider] Update operations unsupported. URI: $uri")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val REFRESH_LIBRARY = "refresh"
|
|
||||||
const val RESET_LIBRARY = "reset"
|
|
||||||
private const val AUTHORITY = "content://${BuildConfig.APPLICATION_ID}.provider"
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val URI_FOLDER: Uri = Uri.parse("$AUTHORITY/${GameDatabase.TABLE_NAME_FOLDERS}/")
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val URI_REFRESH: Uri = Uri.parse("$AUTHORITY/$REFRESH_LIBRARY/")
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val URI_RESET: Uri = Uri.parse("$AUTHORITY/$RESET_LIBRARY/")
|
|
||||||
const val MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.yuzu.folder"
|
|
||||||
const val MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.yuzu.game"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
|
class GamesViewModel : ViewModel() {
|
||||||
|
private val _games = MutableLiveData<ArrayList<Game>>()
|
||||||
|
val games: LiveData<ArrayList<Game>> get() = _games
|
||||||
|
|
||||||
|
init {
|
||||||
|
_games.value = ArrayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGames(games: ArrayList<Game>) {
|
||||||
|
_games.value = games
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -28,7 +29,6 @@ import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
|
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
|
||||||
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
|
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
|
||||||
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
||||||
import org.yuzu.yuzu_emu.model.GameProvider
|
|
||||||
import org.yuzu.yuzu_emu.ui.platform.PlatformGamesFragment
|
import org.yuzu.yuzu_emu.ui.platform.PlatformGamesFragment
|
||||||
import org.yuzu.yuzu_emu.utils.*
|
import org.yuzu.yuzu_emu.utils.*
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -82,11 +82,6 @@ class MainActivity : AppCompatActivity(), MainView {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
presenter.addDirIfNeeded(AddDirectoryHelper(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.menu_game_grid, menu)
|
menuInflater.inflate(R.menu.menu_game_grid, menu)
|
||||||
return true
|
return true
|
||||||
|
@ -99,11 +94,6 @@ class MainActivity : AppCompatActivity(), MainView {
|
||||||
binding.toolbarMain.subtitle = version
|
binding.toolbarMain.subtitle = version
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh() {
|
|
||||||
contentResolver.insert(GameProvider.URI_REFRESH, null)
|
|
||||||
refreshFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun launchSettingsActivity(menuTag: String) {
|
override fun launchSettingsActivity(menuTag: String) {
|
||||||
SettingsActivity.launch(this, menuTag, "")
|
SettingsActivity.launch(this, menuTag, "")
|
||||||
}
|
}
|
||||||
|
@ -185,10 +175,9 @@ class MainActivity : AppCompatActivity(), MainView {
|
||||||
|
|
||||||
// When a new directory is picked, we currently will reset the existing games
|
// When a new directory is picked, we currently will reset the existing games
|
||||||
// database. This effectively means that only one game directory is supported.
|
// database. This effectively means that only one game directory is supported.
|
||||||
// TODO(bunnei): Consider fixing this in the future, or removing code for this.
|
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
|
||||||
contentResolver.insert(GameProvider.URI_RESET, null)
|
.putString(GameHelper.KEY_GAME_PATH, result.toString())
|
||||||
// Add the new directory
|
.apply()
|
||||||
presenter.onDirectorySelected(result.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val getProdKey =
|
private val getProdKey =
|
||||||
|
|
|
@ -5,17 +5,12 @@ package org.yuzu.yuzu_emu.ui.main
|
||||||
|
|
||||||
import org.yuzu.yuzu_emu.BuildConfig
|
import org.yuzu.yuzu_emu.BuildConfig
|
||||||
import org.yuzu.yuzu_emu.R
|
import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication
|
|
||||||
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
import org.yuzu.yuzu_emu.utils.AddDirectoryHelper
|
|
||||||
|
|
||||||
class MainPresenter(private val view: MainView) {
|
class MainPresenter(private val view: MainView) {
|
||||||
private var dirToAdd: String? = null
|
|
||||||
|
|
||||||
fun onCreate() {
|
fun onCreate() {
|
||||||
val versionName = BuildConfig.VERSION_NAME
|
val versionName = BuildConfig.VERSION_NAME
|
||||||
view.setVersionString(versionName)
|
view.setVersionString(versionName)
|
||||||
refreshGameList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchFileListActivity(request: Int) {
|
private fun launchFileListActivity(request: Int) {
|
||||||
|
@ -48,23 +43,6 @@ class MainPresenter(private val view: MainView) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addDirIfNeeded(helper: AddDirectoryHelper) {
|
|
||||||
if (dirToAdd != null) {
|
|
||||||
helper.addDirectory(dirToAdd) { view.refresh() }
|
|
||||||
dirToAdd = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDirectorySelected(dir: String?) {
|
|
||||||
dirToAdd = dir
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshGameList() {
|
|
||||||
val databaseHelper = YuzuApplication.databaseHelper
|
|
||||||
databaseHelper!!.scanLibrary(databaseHelper.writableDatabase)
|
|
||||||
view.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val REQUEST_ADD_DIRECTORY = 1
|
const val REQUEST_ADD_DIRECTORY = 1
|
||||||
const val REQUEST_INSTALL_KEYS = 2
|
const val REQUEST_INSTALL_KEYS = 2
|
||||||
|
|
|
@ -17,10 +17,7 @@ interface MainView {
|
||||||
*/
|
*/
|
||||||
fun setVersionString(version: String)
|
fun setVersionString(version: String)
|
||||||
|
|
||||||
/**
|
|
||||||
* Tell the view to refresh its contents.
|
|
||||||
*/
|
|
||||||
fun refresh()
|
|
||||||
fun launchSettingsActivity(menuTag: String)
|
fun launchSettingsActivity(menuTag: String)
|
||||||
|
|
||||||
fun launchFileListActivity(request: Int)
|
fun launchFileListActivity(request: Int)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.ui.platform
|
package org.yuzu.yuzu_emu.ui.platform
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -13,36 +12,40 @@ import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import org.yuzu.yuzu_emu.R
|
import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication
|
|
||||||
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
||||||
import org.yuzu.yuzu_emu.databinding.FragmentGridBinding
|
import org.yuzu.yuzu_emu.databinding.FragmentGridBinding
|
||||||
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
|
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.utils.GameHelper
|
||||||
|
|
||||||
class PlatformGamesFragment : Fragment(), PlatformGamesView {
|
class PlatformGamesFragment : Fragment() {
|
||||||
private val presenter = PlatformGamesPresenter(this)
|
|
||||||
|
|
||||||
private var _binding: FragmentGridBinding? = null
|
private var _binding: FragmentGridBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var gamesViewModel: GamesViewModel
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
presenter.onCreateView()
|
|
||||||
_binding = FragmentGridBinding.inflate(inflater)
|
_binding = FragmentGridBinding.inflate(inflater)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
gamesViewModel = ViewModelProvider(requireActivity())[GamesViewModel::class.java]
|
||||||
|
|
||||||
binding.gridGames.apply {
|
binding.gridGames.apply {
|
||||||
layoutManager = AutofitGridLayoutManager(
|
layoutManager = AutofitGridLayoutManager(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
||||||
)
|
)
|
||||||
adapter = GameAdapter(requireActivity() as AppCompatActivity)
|
adapter =
|
||||||
|
GameAdapter(requireActivity() as AppCompatActivity, gamesViewModel.games.value!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add swipe down to refresh gesture
|
// Add swipe down to refresh gesture
|
||||||
|
@ -59,7 +62,19 @@ class PlatformGamesFragment : Fragment(), PlatformGamesView {
|
||||||
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
|
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
gamesViewModel.games.observe(viewLifecycleOwner) {
|
||||||
|
(binding.gridGames.adapter as GameAdapter).swapData(it)
|
||||||
|
updateTextView()
|
||||||
|
}
|
||||||
|
|
||||||
setInsets()
|
setInsets()
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
@ -67,20 +82,8 @@ class PlatformGamesFragment : Fragment(), PlatformGamesView {
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh() {
|
fun refresh() {
|
||||||
val databaseHelper = YuzuApplication.databaseHelper
|
gamesViewModel.setGames(GameHelper.getGames())
|
||||||
databaseHelper!!.scanLibrary(databaseHelper.writableDatabase)
|
|
||||||
presenter.refresh()
|
|
||||||
updateTextView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun showGames(games: Cursor) {
|
|
||||||
if (_binding == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (binding.gridGames.adapter != null) {
|
|
||||||
(binding.gridGames.adapter as GameAdapter).swapCursor(games)
|
|
||||||
}
|
|
||||||
updateTextView()
|
updateTextView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.ui.platform
|
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication
|
|
||||||
import org.yuzu.yuzu_emu.utils.Log
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import rx.schedulers.Schedulers
|
|
||||||
|
|
||||||
class PlatformGamesPresenter(private val view: PlatformGamesView) {
|
|
||||||
fun onCreateView() {
|
|
||||||
loadGames()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
Log.debug("[PlatformGamesPresenter] : Refreshing...")
|
|
||||||
loadGames()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadGames() {
|
|
||||||
Log.debug("[PlatformGamesPresenter] : Loading games...")
|
|
||||||
val databaseHelper = YuzuApplication.databaseHelper
|
|
||||||
databaseHelper!!.games
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe { games: Cursor? ->
|
|
||||||
Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor...")
|
|
||||||
view.showGames(games!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.ui.platform
|
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstraction for a screen representing a single platform's games.
|
|
||||||
*/
|
|
||||||
interface PlatformGamesView {
|
|
||||||
/**
|
|
||||||
* Tell the view to refresh its contents.
|
|
||||||
*/
|
|
||||||
fun refresh()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To be called when an asynchronous database read completes. Passes the
|
|
||||||
* result, in this case a [Cursor], to the view.
|
|
||||||
*
|
|
||||||
* @param games A Cursor containing the games read from the database.
|
|
||||||
*/
|
|
||||||
fun showGames(games: Cursor)
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.utils
|
|
||||||
|
|
||||||
interface Action1<T> {
|
|
||||||
fun call(t: T?)
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.utils
|
|
||||||
|
|
||||||
import android.content.AsyncQueryHandler
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import org.yuzu.yuzu_emu.model.GameDatabase
|
|
||||||
import org.yuzu.yuzu_emu.model.GameProvider
|
|
||||||
|
|
||||||
class AddDirectoryHelper(private val context: Context) {
|
|
||||||
fun addDirectory(dir: String?, onAddUnit: () -> Unit) {
|
|
||||||
val handler: AsyncQueryHandler = object : AsyncQueryHandler(context.contentResolver) {
|
|
||||||
override fun onInsertComplete(token: Int, cookie: Any?, uri: Uri) {
|
|
||||||
onAddUnit.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val file = ContentValues()
|
|
||||||
file.put(GameDatabase.KEY_FOLDER_PATH, dir)
|
|
||||||
handler.startInsert(
|
|
||||||
0, // We don't need to identify this call to the handler
|
|
||||||
null, // We don't need to pass additional data to the handler
|
|
||||||
GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder
|
|
||||||
file
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
object GameHelper {
|
||||||
|
const val KEY_GAME_PATH = "game_path"
|
||||||
|
|
||||||
|
fun getGames(): ArrayList<Game> {
|
||||||
|
val games = ArrayList<Game>()
|
||||||
|
val context = YuzuApplication.appContext
|
||||||
|
val gamesDir =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
|
||||||
|
val gamesUri = Uri.parse(gamesDir)
|
||||||
|
|
||||||
|
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||||
|
NativeLibrary.ReloadKeys()
|
||||||
|
|
||||||
|
val children = FileUtil.listFiles(context, gamesUri)
|
||||||
|
for (file in children) {
|
||||||
|
if (!file.isDirectory) {
|
||||||
|
val filename = file.uri.toString()
|
||||||
|
val extensionStart = filename.lastIndexOf('.')
|
||||||
|
if (extensionStart > 0) {
|
||||||
|
val fileExtension = filename.substring(extensionStart)
|
||||||
|
|
||||||
|
// Check that the file has an extension we care about before trying to read out of it.
|
||||||
|
if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
|
||||||
|
games.add(getGame(filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGame(filePath: String): Game {
|
||||||
|
var name = NativeLibrary.GetTitle(filePath)
|
||||||
|
|
||||||
|
// If the game's title field is empty, use the filename.
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
name = filePath.substring(filePath.lastIndexOf("/") + 1)
|
||||||
|
}
|
||||||
|
var gameId = NativeLibrary.GetGameId(filePath)
|
||||||
|
|
||||||
|
// If the game's ID field is empty, use the filename without extension.
|
||||||
|
if (gameId.isEmpty()) {
|
||||||
|
gameId = filePath.substring(
|
||||||
|
filePath.lastIndexOf("/") + 1,
|
||||||
|
filePath.lastIndexOf(".")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Game(
|
||||||
|
name,
|
||||||
|
NativeLibrary.GetDescription(filePath).replace("\n", " "),
|
||||||
|
NativeLibrary.GetRegions(filePath),
|
||||||
|
filePath,
|
||||||
|
gameId,
|
||||||
|
NativeLibrary.GetCompany(filePath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.viewholders
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.yuzu.yuzu_emu.databinding.CardGameBinding
|
|
||||||
import org.yuzu.yuzu_emu.model.Game
|
|
||||||
|
|
||||||
class GameViewHolder(val binding: CardGameBinding) : RecyclerView.ViewHolder(binding.root) {
|
|
||||||
lateinit var game: Game
|
|
||||||
|
|
||||||
init {
|
|
||||||
itemView.tag = this
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue