diff options
Diffstat (limited to 'src')
18 files changed, 154 insertions, 773 deletions
diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index 22f2d4b80..a82d2706b 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -140,10 +140,6 @@ dependencies { implementation "io.coil-kt:coil:2.2.2" implementation 'androidx.core:core-splashscreen: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 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index a5c063d52..18539af80 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -66,23 +66,6 @@ <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 android:name=".features.DocumentProvider" android:authorities="${applicationId}.user" android:grantUriPermissions="true" diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index a0c5c5c25..f81b4da40 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -7,7 +7,6 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context -import org.yuzu.yuzu_emu.model.GameDatabase import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DocumentsTree import org.yuzu.yuzu_emu.utils.GpuDriverHelper @@ -45,12 +44,9 @@ class YuzuApplication : Application() { // TODO(bunnei): Disable notifications until we support app suspension. //createNotificationChannel(); - databaseHelper = GameDatabase(this) } companion object { - var databaseHelper: GameDatabase? = null - @JvmField var documentsTree: DocumentsTree? = null lateinit var application: YuzuApplication diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index 7a0969a55..024676185 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -3,11 +3,8 @@ package org.yuzu.yuzu_emu.adapters -import android.database.Cursor -import android.database.DataSetObserver import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -16,7 +13,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import coil.load -import com.google.android.material.color.MaterialColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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.activities.EmulationActivity import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.GameDatabase -import org.yuzu.yuzu_emu.utils.Log -import org.yuzu.yuzu_emu.viewholders.GameViewHolder -import java.util.* -import java.util.stream.Stream +import kotlin.collections.ArrayList /** * 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) * 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 { - 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 { // Create a new view. val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context)) @@ -60,131 +41,55 @@ class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapte } override fun onBindViewHolder(holder: GameViewHolder, position: Int) { - if (isDatasetValid) { - 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.") - } + holder.bind(games[position]) } override fun getItemCount(): Int { - if (isDatasetValid && cursor != null) { - return cursor!!.count - } - Log.error("[GameAdapter] Dataset is not valid.") - return 0 + return games.size } /** - * Return the contents of the _id column for a given row. + * Launches the game that was clicked on. * - * @param position The row for which Android wants an ID. - * @return A valid ID from the database, or 0 if not available. + * @param view The card representing the game the user wants to play. */ - 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 + override fun onClick(view: View) { + val holder = view.tag as GameViewHolder + EmulationActivity.launch((view.context as AppCompatActivity), holder.game) } - /** - * 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) - } + inner class GameViewHolder(val binding: CardGameBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var game: Game - /** - * 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 + init { + itemView.tag = this } - // 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) + 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) + } } - true - } else { - false - } - notifyDataSetChanged() - } - /** - * Launches the game that was clicked on. - * - * @param view The card representing the game the user wants to play. - */ - override fun onClick(view: View) { - val holder = view.tag as GameViewHolder - EmulationActivity.launch((view.context as AppCompatActivity), holder.game) - } + binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ") + binding.textGameCaption.text = game.company - private fun isValidGame(path: String): Boolean { - return Stream.of(".rar", ".zip", ".7z", ".torrent", ".tar", ".gz") - .noneMatch { suffix: String? -> - path.lowercase(Locale.getDefault()).endsWith(suffix!!) + if (game.company.isEmpty()) { + binding.textGameCaption.visibility = View.GONE } + } + } + + fun swapData(games: ArrayList<Game>) { + this.games = games + notifyDataSetChanged() } private fun decodeGameIcon(uri: String): Bitmap? { @@ -196,18 +101,4 @@ class GameAdapter(private val activity: AppCompatActivity) : RecyclerView.Adapte BitmapFactory.Options() ) } - - private inner class GameDataSetObserver : DataSetObserver() { - override fun onChanged() { - super.onChanged() - isDatasetValid = true - notifyDataSetChanged() - } - - override fun onInvalidated() { - super.onInvalidated() - isDatasetValid = false - notifyDataSetChanged() - } - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt index 91f6c5d75..db494e40f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -3,11 +3,8 @@ package org.yuzu.yuzu_emu.model -import android.content.ContentValues -import android.database.Cursor import android.os.Parcelable import kotlinx.parcelize.Parcelize -import java.nio.file.Paths import java.util.HashSet @Parcelize @@ -23,40 +20,5 @@ class Game( val extensions: Set<String> = HashSet( 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) - ) - } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.kt deleted file mode 100644 index c66183516..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.kt +++ /dev/null @@ -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)) - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProvider.kt deleted file mode 100644 index 5d8e5cc54..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProvider.kt +++ /dev/null @@ -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" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt new file mode 100644 index 000000000..fde99f1a2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -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 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 441c9da9c..4885bc4bc 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers 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.DialogProgressBarBinding 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.utils.* 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 { menuInflater.inflate(R.menu.menu_game_grid, menu) return true @@ -99,11 +94,6 @@ class MainActivity : AppCompatActivity(), MainView { binding.toolbarMain.subtitle = version } - override fun refresh() { - contentResolver.insert(GameProvider.URI_REFRESH, null) - refreshFragment() - } - override fun launchSettingsActivity(menuTag: String) { 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 // database. This effectively means that only one game directory is supported. - // TODO(bunnei): Consider fixing this in the future, or removing code for this. - contentResolver.insert(GameProvider.URI_RESET, null) - // Add the new directory - presenter.onDirectorySelected(result.toString()) + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() } private val getProdKey = diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt index 554542e05..a7ddc333f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt @@ -5,17 +5,12 @@ package org.yuzu.yuzu_emu.ui.main import org.yuzu.yuzu_emu.BuildConfig 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.utils.AddDirectoryHelper class MainPresenter(private val view: MainView) { - private var dirToAdd: String? = null - fun onCreate() { val versionName = BuildConfig.VERSION_NAME view.setVersionString(versionName) - refreshGameList() } private fun launchFileListActivity(request: Int) { @@ -48,23 +43,6 @@ class MainPresenter(private val view: MainView) { 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 { const val REQUEST_ADD_DIRECTORY = 1 const val REQUEST_INSTALL_KEYS = 2 diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt index dab3abe7c..4dc9f0706 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt @@ -17,10 +17,7 @@ interface MainView { */ fun setVersionString(version: String) - /** - * Tell the view to refresh its contents. - */ - fun refresh() fun launchSettingsActivity(menuTag: String) + fun launchFileListActivity(request: Int) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt index dcfac1b2a..443a37cd2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt @@ -3,7 +3,6 @@ package org.yuzu.yuzu_emu.ui.platform -import android.database.Cursor import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -13,36 +12,40 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.google.android.material.color.MaterialColors import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.adapters.GameAdapter import org.yuzu.yuzu_emu.databinding.FragmentGridBinding 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 { - private val presenter = PlatformGamesPresenter(this) - +class PlatformGamesFragment : Fragment() { private var _binding: FragmentGridBinding? = null private val binding get() = _binding!! + private lateinit var gamesViewModel: GamesViewModel + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - presenter.onCreateView() _binding = FragmentGridBinding.inflate(inflater) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + gamesViewModel = ViewModelProvider(requireActivity())[GamesViewModel::class.java] + binding.gridGames.apply { layoutManager = AutofitGridLayoutManager( requireContext(), 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 @@ -59,7 +62,19 @@ class PlatformGamesFragment : Fragment(), PlatformGamesView { MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary) ) + gamesViewModel.games.observe(viewLifecycleOwner) { + (binding.gridGames.adapter as GameAdapter).swapData(it) + updateTextView() + } + setInsets() + + refresh() + } + + override fun onResume() { + super.onResume() + refresh() } override fun onDestroyView() { @@ -67,20 +82,8 @@ class PlatformGamesFragment : Fragment(), PlatformGamesView { _binding = null } - override fun refresh() { - val databaseHelper = YuzuApplication.databaseHelper - 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) - } + fun refresh() { + gamesViewModel.setGames(GameHelper.getGames()) updateTextView() } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesPresenter.kt deleted file mode 100644 index 0b9da5f57..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesPresenter.kt +++ /dev/null @@ -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!!) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesView.kt deleted file mode 100644 index 4132e560f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesView.kt +++ /dev/null @@ -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) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Action1.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Action1.kt deleted file mode 100644 index 9041a7bee..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Action1.kt +++ /dev/null @@ -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?) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddDirectoryHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddDirectoryHelper.kt deleted file mode 100644 index acec7ba5e..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddDirectoryHelper.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt new file mode 100644 index 000000000..6dfd8b7f8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -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) + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholders/GameViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholders/GameViewHolder.kt deleted file mode 100644 index 51420448f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholders/GameViewHolder.kt +++ /dev/null @@ -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 - } -} |