summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDatabase.kt
blob: c66183516b51127b03f0080a34f87d01807ed7ee (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
// 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))
            }
        }
    }
}