mirror of
https://github.com/izzy2lost/xemu.git
synced 2026-07-06 00:20:22 -07:00
add game folder for game list
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -35,5 +35,10 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".GameLibraryActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<int>(parsed);
|
||||
}
|
||||
|
||||
static JNIEnv* GetEnv() {
|
||||
return static_cast<JNIEnv*>(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<std::string> 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<char*> xemu_argv;
|
||||
xemu_argv.reserve(arg_storage.size() + 1);
|
||||
|
||||
@@ -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<bool>()) {
|
||||
setenv("XEMU_ANDROID_FORCE_CPU_BLIT", *force_cpu_blit ? "1" : "0", 1);
|
||||
}
|
||||
if (auto egl_offscreen = android_cfg["egl_offscreen"].value<bool>()) {
|
||||
if (!*egl_offscreen) {
|
||||
setenv("XEMU_ANDROID_EGL_OFFSCREEN", "0", 1);
|
||||
}
|
||||
}
|
||||
if (auto tcg_tuning = android_cfg["tcg_tuning"].value<bool>()) {
|
||||
setenv("XEMU_ANDROID_TCG_TUNING", *tcg_tuning ? "1" : "0", 1);
|
||||
}
|
||||
if (auto tcg_thread = android_cfg["tcg_thread"].value<std::string>()) {
|
||||
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<int64_t>()) {
|
||||
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<std::string>()) {
|
||||
|
||||
@@ -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<GameEntry>) {
|
||||
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<TextView>(R.id.game_name_text)
|
||||
val sizeText = item.findViewById<TextView>(R.id.game_size_text)
|
||||
val pathText = item.findViewById<TextView>(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<GameEntry> {
|
||||
val root = DocumentFile.fromTreeUri(this, folderUri) ?: return emptyList()
|
||||
val stack = ArrayDeque<Pair<DocumentFile, String>>()
|
||||
stack.add(root to "")
|
||||
|
||||
val games = ArrayList<GameEntry>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/setup_wizard_background"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/library_scroll"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:padding="24dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/xemu_surface"
|
||||
app:cardCornerRadius="28dp"
|
||||
app:cardElevation="8dp"
|
||||
app:strokeColor="@color/xemu_outline_variant"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/library_title"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/library_subtitle"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="@color/xemu_text_muted" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/library_folder_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/setup_wizard_path_background"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:text="@string/library_no_folder"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="@color/xemu_text_muted" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_change_games_folder"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/library_change_folder"
|
||||
app:icon="@android:drawable/ic_menu_set_as"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/library_loading"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:indeterminateTint="@color/xemu_green"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/library_loading_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="@string/library_loading_games"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="@color/xemu_text_muted"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/library_empty_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:background="@drawable/setup_wizard_path_background"
|
||||
android:text="@string/library_empty_games"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="@color/xemu_text_muted"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/library_games_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -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" />
|
||||
|
||||
<TextView
|
||||
@@ -268,7 +268,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/setup_pick_disc"
|
||||
app:icon="@android:drawable/ic_menu_slideshow"
|
||||
app:icon="@android:drawable/ic_menu_agenda"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp" />
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/xemu_surface_variant"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:rippleColor="@color/xemu_green"
|
||||
app:strokeColor="@color/xemu_outline"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/library_open_game"
|
||||
android:src="@android:drawable/ic_menu_slideshow"
|
||||
android:tint="@color/xemu_green" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/game_name_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/game_size_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="@color/xemu_text_muted" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/game_path_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="@color/xemu_text_muted" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/library_open_game"
|
||||
android:src="@android:drawable/ic_media_play"
|
||||
android:tint="@color/xemu_green" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -22,8 +22,19 @@
|
||||
<string name="setup_pick_hdd">Choose Hard Disk</string>
|
||||
<string name="setup_hdd_value">Hard Disk: %1$s</string>
|
||||
|
||||
<string name="setup_disc_title">Game Disc (Optional)</string>
|
||||
<string name="setup_disc_body">Choose an Xbox game disc image to boot.</string>
|
||||
<string name="setup_pick_disc">Choose Game Disc</string>
|
||||
<string name="setup_disc_value">Game Disc: %1$s</string>
|
||||
<string name="setup_disc_title">Games Folder</string>
|
||||
<string name="setup_disc_body">Pick your games folder using SAF. X1 BOX will scan it for playable images.</string>
|
||||
<string name="setup_pick_disc">Choose Games Folder</string>
|
||||
<string name="setup_disc_value">Games Folder: %1$s</string>
|
||||
|
||||
<string name="library_title">Game Library</string>
|
||||
<string name="library_subtitle">Pick a game to launch in xemu.</string>
|
||||
<string name="library_change_folder">Change Folder</string>
|
||||
<string name="library_folder_value">Folder: %1$s</string>
|
||||
<string name="library_no_folder">No folder selected</string>
|
||||
<string name="library_loading_games">Scanning games...</string>
|
||||
<string name="library_empty_games">No supported games found in this folder.</string>
|
||||
<string name="library_game_size">Size: %1$s</string>
|
||||
<string name="library_game_path">Path: %1$s</string>
|
||||
<string name="library_open_game">Open</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user