diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 06fdeceb57..f6edfb41b1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -104,6 +104,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.android.material:material:1.14.0-alpha07")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 671c7a91f5..66540a7a79 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -35,5 +35,10 @@
android:screenOrientation="fullSensor"
android:exported="false" />
+
+
diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp
index 7f0b840866..b25ab3ebbb 100644
--- a/android/app/src/main/cpp/xemu_android.cpp
+++ b/android/app/src/main/cpp/xemu_android.cpp
@@ -9,6 +9,8 @@
#include
#include
+#include
+#include
#include
#include
#include
@@ -57,6 +59,42 @@ static bool FileExists(const std::string& path) {
return stat(path.c_str(), &st) == 0;
}
+static bool IsTcgTuningEnabled() {
+ const char* value = SDL_getenv("XEMU_ANDROID_TCG_TUNING");
+ return !(value && value[0] == '0');
+}
+
+static const char* GetTcgThreadFromEnv() {
+ const char* value = SDL_getenv("XEMU_ANDROID_TCG_THREAD");
+ if (value && strcmp(value, "single") == 0) {
+ return "single";
+ }
+ return "multi";
+}
+
+static int GetTcgTbSizeFromEnv() {
+ constexpr int kDefaultTbSize = 128;
+ constexpr int kMinTbSize = 32;
+ constexpr int kMaxTbSize = 512;
+
+ const char* value = SDL_getenv("XEMU_ANDROID_TCG_TB_SIZE");
+ if (!value || value[0] == '\0') {
+ return kDefaultTbSize;
+ }
+
+ char* end = nullptr;
+ long parsed = strtol(value, &end, 10);
+ if (end == value || (end && *end != '\0')) {
+ return kDefaultTbSize;
+ }
+ if (parsed < kMinTbSize) {
+ parsed = kMinTbSize;
+ } else if (parsed > kMaxTbSize) {
+ parsed = kMaxTbSize;
+ }
+ return static_cast(parsed);
+}
+
static JNIEnv* GetEnv() {
return static_cast(SDL_AndroidGetJNIEnv());
}
@@ -221,6 +259,15 @@ static bool WriteConfigToml(const std::string& config_path,
if (!android->contains("force_cpu_blit")) {
android->insert_or_assign("force_cpu_blit", false);
}
+ if (!android->contains("tcg_tuning")) {
+ android->insert_or_assign("tcg_tuning", true);
+ }
+ if (!android->contains("tcg_thread")) {
+ android->insert_or_assign("tcg_thread", "multi");
+ }
+ if (!android->contains("tcg_tb_size")) {
+ android->insert_or_assign("tcg_tb_size", 128);
+ }
files->insert_or_assign("bootrom_path", mcpx);
files->insert_or_assign("flashrom_path", flash);
@@ -454,9 +501,18 @@ extern "C" int SDL_main(int argc, char* argv[]) {
std::vector arg_storage;
arg_storage.emplace_back("xemu");
- arg_storage.emplace_back("-accel");
- arg_storage.emplace_back("tcg,thread=multi,tb-size=256");
- LogInfo("SDL_main: forcing TCG accel thread=multi tb-size=256");
+ if (IsTcgTuningEnabled()) {
+ const char* tcg_thread = GetTcgThreadFromEnv();
+ int tcg_tb_size = GetTcgTbSizeFromEnv();
+ char accel_opts[64];
+ snprintf(accel_opts, sizeof(accel_opts), "tcg,thread=%s,tb-size=%d",
+ tcg_thread, tcg_tb_size);
+ arg_storage.emplace_back("-accel");
+ arg_storage.emplace_back(accel_opts);
+ LogInfoFmt("SDL_main: using accel %s", accel_opts);
+ } else {
+ LogInfo("SDL_main: TCG tuning disabled");
+ }
std::vector xemu_argv;
xemu_argv.reserve(arg_storage.size() + 1);
diff --git a/android/app/src/main/cpp/xemu_settings_android.cc b/android/app/src/main/cpp/xemu_settings_android.cc
index 7ba879bd2a..ead262ae43 100644
--- a/android/app/src/main/cpp/xemu_settings_android.cc
+++ b/android/app/src/main/cpp/xemu_settings_android.cc
@@ -198,6 +198,9 @@ bool xemu_settings_load(void)
xemu_settings_apply_defaults();
error_msg.clear();
setenv("XEMU_ANDROID_FORCE_CPU_BLIT", "0", 1);
+ setenv("XEMU_ANDROID_TCG_TUNING", "1", 1);
+ setenv("XEMU_ANDROID_TCG_THREAD", "multi", 1);
+ setenv("XEMU_ANDROID_TCG_TB_SIZE", "128", 1);
const char *path = xemu_settings_get_path();
if (!path || *path == '\0') {
@@ -256,11 +259,37 @@ bool xemu_settings_load(void)
}
// Android-specific settings
+ if (auto force_cpu_blit = android_cfg["force_cpu_blit"].value()) {
+ setenv("XEMU_ANDROID_FORCE_CPU_BLIT", *force_cpu_blit ? "1" : "0", 1);
+ }
if (auto egl_offscreen = android_cfg["egl_offscreen"].value()) {
if (!*egl_offscreen) {
setenv("XEMU_ANDROID_EGL_OFFSCREEN", "0", 1);
}
}
+ if (auto tcg_tuning = android_cfg["tcg_tuning"].value()) {
+ setenv("XEMU_ANDROID_TCG_TUNING", *tcg_tuning ? "1" : "0", 1);
+ }
+ if (auto tcg_thread = android_cfg["tcg_thread"].value()) {
+ if (*tcg_thread == "single" || *tcg_thread == "multi") {
+ setenv("XEMU_ANDROID_TCG_THREAD", tcg_thread->c_str(), 1);
+ } else {
+ __android_log_print(ANDROID_LOG_WARN, "xemu-android",
+ "Ignoring android.tcg_thread=%s (expected single|multi)",
+ tcg_thread->c_str());
+ }
+ }
+ if (auto tcg_tb_size = android_cfg["tcg_tb_size"].value()) {
+ int tb_size = (int)*tcg_tb_size;
+ if (tb_size < 32) {
+ tb_size = 32;
+ } else if (tb_size > 512) {
+ tb_size = 512;
+ }
+ char tb_size_str[16];
+ snprintf(tb_size_str, sizeof(tb_size_str), "%d", tb_size);
+ setenv("XEMU_ANDROID_TCG_TB_SIZE", tb_size_str, 1);
+ }
// System file paths
if (auto bootrom = sys_files["bootrom_path"].value()) {
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt
new file mode 100644
index 0000000000..87a5e1251a
--- /dev/null
+++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt
@@ -0,0 +1,240 @@
+package com.izzy2lost.x1box
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import com.google.android.material.button.MaterialButton
+import java.util.ArrayDeque
+import java.util.Locale
+
+class GameLibraryActivity : AppCompatActivity() {
+ private data class GameEntry(
+ val title: String,
+ val uri: Uri,
+ val relativePath: String,
+ val sizeBytes: Long
+ )
+
+ private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) }
+ private val gameExts = setOf("iso", "xiso", "cso", "cci")
+
+ private lateinit var folderText: TextView
+ private lateinit var loadingSpinner: ProgressBar
+ private lateinit var loadingText: TextView
+ private lateinit var emptyText: TextView
+ private lateinit var gamesContainer: LinearLayout
+ private lateinit var btnChangeFolder: MaterialButton
+
+ private var gamesFolderUri: Uri? = null
+ private var scanGeneration = 0
+
+ private val pickGamesFolder =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
+ if (uri != null) {
+ persistUriPermission(uri)
+ gamesFolderUri = uri
+ prefs.edit().putString("gamesFolderUri", uri.toString()).apply()
+ loadGames()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_game_library)
+
+ folderText = findViewById(R.id.library_folder_text)
+ loadingSpinner = findViewById(R.id.library_loading)
+ loadingText = findViewById(R.id.library_loading_text)
+ emptyText = findViewById(R.id.library_empty_text)
+ gamesContainer = findViewById(R.id.library_games_container)
+ btnChangeFolder = findViewById(R.id.btn_change_games_folder)
+
+ gamesFolderUri = prefs.getString("gamesFolderUri", null)?.let(Uri::parse)
+
+ btnChangeFolder.setOnClickListener {
+ pickGamesFolder.launch(gamesFolderUri)
+ }
+
+ if (!isFolderReady(gamesFolderUri)) {
+ folderText.text = getString(R.string.library_no_folder)
+ Toast.makeText(this, getString(R.string.setup_pick_disc), Toast.LENGTH_SHORT).show()
+ pickGamesFolder.launch(gamesFolderUri)
+ return
+ }
+
+ loadGames()
+ }
+
+ private fun loadGames() {
+ val folderUri = gamesFolderUri
+ if (!isFolderReady(folderUri)) {
+ setLoading(false)
+ setGames(emptyList())
+ folderText.text = getString(R.string.library_no_folder)
+ return
+ }
+
+ folderText.text = getString(R.string.library_folder_value, formatTreeLabel(folderUri!!))
+ setLoading(true)
+
+ val generation = ++scanGeneration
+ Thread {
+ val games = scanFolderForGames(folderUri)
+ runOnUiThread {
+ if (generation != scanGeneration) {
+ return@runOnUiThread
+ }
+ setLoading(false)
+ setGames(games)
+ }
+ }.start()
+ }
+
+ private fun setLoading(loading: Boolean) {
+ loadingSpinner.visibility = if (loading) View.VISIBLE else View.GONE
+ loadingText.visibility = if (loading) View.VISIBLE else View.GONE
+ }
+
+ private fun setGames(games: List) {
+ gamesContainer.removeAllViews()
+ emptyText.visibility = if (games.isEmpty()) View.VISIBLE else View.GONE
+ if (games.isEmpty()) {
+ return
+ }
+
+ val inflater = LayoutInflater.from(this)
+ for (game in games) {
+ val item = inflater.inflate(R.layout.item_game_entry, gamesContainer, false)
+ val nameText = item.findViewById(R.id.game_name_text)
+ val sizeText = item.findViewById(R.id.game_size_text)
+ val pathText = item.findViewById(R.id.game_path_text)
+
+ nameText.text = game.title
+ sizeText.text = getString(R.string.library_game_size, formatSize(game.sizeBytes))
+ pathText.text = getString(R.string.library_game_path, game.relativePath)
+
+ item.setOnClickListener { launchGame(game) }
+ gamesContainer.addView(item)
+ }
+ }
+
+ private fun launchGame(game: GameEntry) {
+ persistUriPermission(game.uri)
+ prefs.edit()
+ .putString("dvdUri", game.uri.toString())
+ .remove("dvdPath")
+ .putBoolean("skip_game_picker", false)
+ .apply()
+
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ }
+
+ private fun scanFolderForGames(folderUri: Uri): List {
+ val root = DocumentFile.fromTreeUri(this, folderUri) ?: return emptyList()
+ val stack = ArrayDeque>()
+ stack.add(root to "")
+
+ val games = ArrayList()
+ while (stack.isNotEmpty()) {
+ val (node, prefix) = stack.removeLast()
+ val files = try {
+ node.listFiles()
+ } catch (_: Exception) {
+ emptyArray()
+ }
+ for (child in files) {
+ val name = child.name ?: continue
+ if (child.isDirectory) {
+ stack.add(child to (prefix + name + "/"))
+ continue
+ }
+ if (!child.isFile || !isSupportedGame(name)) {
+ continue
+ }
+ games.add(
+ GameEntry(
+ title = toGameTitle(name),
+ uri = child.uri,
+ relativePath = prefix + name,
+ sizeBytes = child.length()
+ )
+ )
+ }
+ }
+
+ games.sortBy { it.title.lowercase(Locale.ROOT) }
+ return games
+ }
+
+ private fun isSupportedGame(name: String): Boolean {
+ val lower = name.lowercase(Locale.ROOT)
+ if (lower.endsWith(".xiso.iso")) {
+ return true
+ }
+ val ext = lower.substringAfterLast('.', "")
+ return ext.isNotEmpty() && gameExts.contains(ext)
+ }
+
+ private fun toGameTitle(fileName: String): String {
+ val lower = fileName.lowercase(Locale.ROOT)
+ return when {
+ lower.endsWith(".xiso.iso") -> fileName.dropLast(".xiso.iso".length)
+ fileName.contains('.') -> fileName.substringBeforeLast('.')
+ else -> fileName
+ }
+ }
+
+ private fun formatSize(bytes: Long): String {
+ if (bytes <= 0L) {
+ return "Unknown"
+ }
+ val units = arrayOf("B", "KB", "MB", "GB", "TB")
+ var value = bytes.toDouble()
+ var unitIndex = 0
+ while (value >= 1024.0 && unitIndex < units.lastIndex) {
+ value /= 1024.0
+ unitIndex++
+ }
+ return String.format(Locale.US, "%.1f %s", value, units[unitIndex])
+ }
+
+ private fun isFolderReady(uri: Uri?): Boolean {
+ if (uri == null || !hasPersistedReadPermission(uri)) {
+ return false
+ }
+ val root = DocumentFile.fromTreeUri(this, uri) ?: return false
+ return root.exists() && root.isDirectory
+ }
+
+ private fun persistUriPermission(uri: Uri) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ try {
+ contentResolver.takePersistableUriPermission(uri, flags)
+ } catch (_: SecurityException) {
+ }
+ }
+
+ private fun hasPersistedReadPermission(uri: Uri): Boolean {
+ return contentResolver.persistedUriPermissions.any { perm ->
+ perm.uri == uri && perm.isReadPermission
+ }
+ }
+
+ private fun formatTreeLabel(uri: Uri): String {
+ val name = DocumentFile.fromTreeUri(this, uri)?.name
+ if (!name.isNullOrBlank()) {
+ return name
+ }
+ return uri.toString()
+ }
+}
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
index 226b21244e..13c085a5f3 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt
@@ -16,21 +16,23 @@ class LauncherActivity : Activity() {
val flashUriStr = prefs.getString("flashUri", null)
val hddUriStr = prefs.getString("hddUri", null)
val dvdUriStr = prefs.getString("dvdUri", null)
+ val gamesFolderUriStr = prefs.getString("gamesFolderUri", null)
val mcpxPath = prefs.getString("mcpxPath", null)
val flashPath = prefs.getString("flashPath", null)
val hddPath = prefs.getString("hddPath", null)
val dvdPath = prefs.getString("dvdPath", null)
- val skipGamePicker = prefs.getBoolean("skip_game_picker", false)
val mcpxUri = mcpxUriStr?.let(Uri::parse)
val flashUri = flashUriStr?.let(Uri::parse)
val hddUri = hddUriStr?.let(Uri::parse)
val dvdUri = dvdUriStr?.let(Uri::parse)
+ val gamesFolderUri = gamesFolderUriStr?.let(Uri::parse)
val hasMcpx = hasLocalFile(mcpxPath) || (mcpxUri != null && hasPersistedReadPermission(mcpxUri))
val hasFlash = hasLocalFile(flashPath) || (flashUri != null && hasPersistedReadPermission(flashUri))
val hasHdd = hasLocalFile(hddPath) || (hddUri != null && hasPersistedReadPermission(hddUri))
val hasDvd = hasLocalFile(dvdPath) || (dvdUri != null && hasPersistedReadPermission(dvdUri))
+ val hasGamesFolder = gamesFolderUri != null && hasPersistedReadPermission(gamesFolderUri)
val editor = prefs.edit()
var clearedCore = false
@@ -67,6 +69,10 @@ class LauncherActivity : Activity() {
editor.remove("dvdPath")
clearedOptional = true
}
+ if (!hasGamesFolder && gamesFolderUriStr != null) {
+ editor.remove("gamesFolderUri")
+ clearedCore = true
+ }
if (clearedCore) {
setupComplete = false
editor.putBoolean("setup_complete", false)
@@ -76,14 +82,8 @@ class LauncherActivity : Activity() {
editor.apply()
}
- val needsSetup = !setupComplete || !hasMcpx || !hasFlash || !hasHdd
- val needsGamePicker = !skipGamePicker && !hasDvd
- val next =
- if (needsSetup || needsGamePicker) {
- SetupWizardActivity::class.java
- } else {
- MainActivity::class.java
- }
+ val needsSetup = !setupComplete || !hasMcpx || !hasFlash || !hasHdd || !hasGamesFolder
+ val next = if (needsSetup) SetupWizardActivity::class.java else GameLibraryActivity::class.java
startActivity(Intent(this, next))
finish()
diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt
index c99ef843cb..ec1bc3cd7b 100644
--- a/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt
+++ b/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt
@@ -10,6 +10,7 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
import com.google.android.material.button.MaterialButton
import java.io.File
import java.io.FileOutputStream
@@ -38,24 +39,16 @@ class SetupWizardActivity : AppCompatActivity() {
private var mcpxUri: Uri? = null
private var flashUri: Uri? = null
private var hddUri: Uri? = null
- private var dvdUri: Uri? = null
+ private var gamesFolderUri: Uri? = null
private var mcpxPath: String? = null
private var flashPath: String? = null
private var hddPath: String? = null
- private var dvdPath: String? = null
private var currentStep = 0
private var isCopying = false
private val mcpxExts = setOf("bin", "rom", "img")
private val flashExts = setOf("bin", "rom", "img")
private val hddExts = setOf("qcow2", "img")
- private val discExts = setOf("iso", "xiso", "cso", "cci")
- private val discMimes = setOf(
- "application/x-iso9660-image",
- "application/x-cd-image",
- "application/x-iso9660",
- "application/vnd.iso-image"
- )
private val pickMcpx =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@@ -126,26 +119,14 @@ class SetupWizardActivity : AppCompatActivity() {
}
}
- private val pickDisc =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ private val pickGamesFolder =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
- if (!isAllowedFile(uri, discExts, discMimes)) {
- showExtensionError(discExts)
- return@registerForActivityResult
- }
persistUriPermission(uri)
- dvdUri = uri
- prefs.edit().putString("dvdUri", uri.toString()).apply()
- copyUriAsync(uri, "dvd.iso") { path ->
- if (path != null) {
- dvdPath = path
- prefs.edit().putString("dvdPath", path).apply()
- } else {
- Toast.makeText(this, "Failed to copy game disc image", Toast.LENGTH_LONG).show()
- }
- updateDiscSelection()
- updateButtons()
- }
+ gamesFolderUri = uri
+ prefs.edit().putString("gamesFolderUri", uri.toString()).apply()
+ updateDiscSelection()
+ updateButtons()
}
}
@@ -155,16 +136,15 @@ class SetupWizardActivity : AppCompatActivity() {
mcpxPath = loadLocalPath("mcpxPath")
flashPath = loadLocalPath("flashPath")
hddPath = loadLocalPath("hddPath")
- dvdPath = loadLocalPath("dvdPath")
mcpxUri = prefs.getString("mcpxUri", null)?.let(Uri::parse)
flashUri = prefs.getString("flashUri", null)?.let(Uri::parse)
hddUri = prefs.getString("hddUri", null)?.let(Uri::parse)
- dvdUri = prefs.getString("dvdUri", null)?.let(Uri::parse)
+ gamesFolderUri = prefs.getString("gamesFolderUri", null)?.let(Uri::parse)
val coreReady = isFileReady(mcpxPath) && isFileReady(flashPath) && isFileReady(hddPath)
- val discReady = isFileReady(dvdPath)
- if (prefs.getBoolean("setup_complete", false) && coreReady && discReady) {
- goToMain()
+ val gamesFolderReady = hasGamesFolderReady()
+ if (prefs.getBoolean("setup_complete", false) && coreReady && gamesFolderReady) {
+ goToLibrary()
return
}
@@ -204,7 +184,7 @@ class SetupWizardActivity : AppCompatActivity() {
btnPickMcpx.setOnClickListener { pickMcpx.launch(arrayOf("application/octet-stream")) }
btnPickFlash.setOnClickListener { pickFlash.launch(arrayOf("application/octet-stream")) }
btnPickHdd.setOnClickListener { pickHdd.launch(arrayOf("application/x-qcow2", "application/octet-stream")) }
- btnPickDisc.setOnClickListener { pickDisc.launch(arrayOf("*/*")) }
+ btnPickDisc.setOnClickListener { pickGamesFolder.launch(gamesFolderUri) }
btnBack.setOnClickListener { showStep(currentStep - 1) }
btnNext.setOnClickListener {
@@ -233,7 +213,7 @@ class SetupWizardActivity : AppCompatActivity() {
updateHddSelection()
updateDiscSelection()
- val startStep = if (coreReady && !discReady) pages.size - 1 else 0
+ val startStep = if (coreReady && !gamesFolderReady) pages.size - 1 else 0
showStep(startStep)
}
@@ -271,7 +251,7 @@ class SetupWizardActivity : AppCompatActivity() {
0 -> isFileReady(mcpxPath)
1 -> isFileReady(flashPath)
2 -> isFileReady(hddPath)
- else -> true
+ else -> hasGamesFolderReady()
}
}
@@ -291,22 +271,21 @@ class SetupWizardActivity : AppCompatActivity() {
}
private fun updateDiscSelection() {
- val value = dvdPath ?: dvdUri?.toString() ?: getString(R.string.setup_not_set)
+ val value = gamesFolderUri?.let { formatTreeLabel(it) } ?: getString(R.string.setup_not_set)
discPathText.text = getString(R.string.setup_disc_value, value)
}
private fun finishSetup() {
- val skipGamePicker = !isFileReady(dvdPath)
prefs.edit()
.putBoolean("setup_complete", true)
- .putBoolean("skip_game_picker", skipGamePicker)
+ .putBoolean("skip_game_picker", false)
.apply()
- goToMain()
+ goToLibrary()
}
- private fun goToMain() {
- startActivity(Intent(this, MainActivity::class.java))
+ private fun goToLibrary() {
+ startActivity(Intent(this, GameLibraryActivity::class.java))
finish()
}
@@ -331,6 +310,29 @@ class SetupWizardActivity : AppCompatActivity() {
return path != null && File(path).isFile
}
+ private fun hasGamesFolderReady(): Boolean {
+ val uri = gamesFolderUri ?: return false
+ if (!hasPersistedReadPermission(uri)) {
+ return false
+ }
+ val root = DocumentFile.fromTreeUri(this, uri) ?: return false
+ return root.exists() && root.isDirectory
+ }
+
+ private fun hasPersistedReadPermission(uri: Uri): Boolean {
+ return contentResolver.persistedUriPermissions.any { perm ->
+ perm.uri == uri && perm.isReadPermission
+ }
+ }
+
+ private fun formatTreeLabel(uri: Uri): String {
+ val name = DocumentFile.fromTreeUri(this, uri)?.name
+ if (!name.isNullOrBlank()) {
+ return name
+ }
+ return uri.toString()
+ }
+
private fun copyUriAsync(uri: Uri, destName: String, onDone: (String?) -> Unit) {
if (isCopying) return
isCopying = true
diff --git a/android/app/src/main/res/layout/activity_game_library.xml b/android/app/src/main/res/layout/activity_game_library.xml
new file mode 100644
index 0000000000..47de8a15f2
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_game_library.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_setup_wizard.xml b/android/app/src/main/res/layout/activity_setup_wizard.xml
index daf036c788..88b6fab895 100644
--- a/android/app/src/main/res/layout/activity_setup_wizard.xml
+++ b/android/app/src/main/res/layout/activity_setup_wizard.xml
@@ -242,7 +242,7 @@
android:layout_height="40dp"
android:layout_gravity="center_horizontal"
android:contentDescription="@string/setup_disc_title"
- android:src="@android:drawable/ic_menu_slideshow"
+ android:src="@android:drawable/ic_menu_agenda"
android:tint="@color/xemu_green" />
diff --git a/android/app/src/main/res/layout/item_game_entry.xml b/android/app/src/main/res/layout/item_game_entry.xml
new file mode 100644
index 0000000000..d6a599c2d6
--- /dev/null
+++ b/android/app/src/main/res/layout/item_game_entry.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 0a47ed9d4a..b25b52e6b0 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -22,8 +22,19 @@
Choose Hard Disk
Hard Disk: %1$s
- Game Disc (Optional)
- Choose an Xbox game disc image to boot.
- Choose Game Disc
- Game Disc: %1$s
+ Games Folder
+ Pick your games folder using SAF. X1 BOX will scan it for playable images.
+ Choose Games Folder
+ Games Folder: %1$s
+
+ Game Library
+ Pick a game to launch in xemu.
+ Change Folder
+ Folder: %1$s
+ No folder selected
+ Scanning games...
+ No supported games found in this folder.
+ Size: %1$s
+ Path: %1$s
+ Open