diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4a071d4..2074a74 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,8 +29,8 @@ android { minSdk = 26 targetSdk = 36 - versionCode = 18 - versionName = "1.1.7" + versionCode = 19 + versionName = "1.1.8" ndk { abiFilters += listOf("arm64-v8a") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a597205..ce5a755 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,17 @@ + + + + + + + + + + + + handleRomLaunchIntent(intent, info) + pendingRomLaunch = null + } } private fun applyThemeFromPrefs() { @@ -437,6 +483,14 @@ class MainActivity : AppCompatActivity() { .show() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + parseRomLaunchIntent(intent)?.let { info -> + handleRomLaunchIntent(intent, info) + } + } + override fun onResume() { super.onResume() applyImmersiveMode() @@ -936,6 +990,248 @@ class MainActivity : AppCompatActivity() { } } + private fun parseRomLaunchIntent(intent: Intent?): RomLaunchInfo? { + if (intent == null) return null + val uri = extractRomUri(intent) ?: return null + val file = resolveFileFromUri(uri) + val (displayName, size) = resolveRomNameAndSize(uri, file) + val baseName = normalizeRomBaseName(displayName) + if (baseName.isBlank()) return null + return RomLaunchInfo( + uri = uri, + displayName = displayName, + baseName = baseName, + file = file, + size = size, + ) + } + + private fun extractRomUri(intent: Intent): Uri? { + intent.data?.let { return it } + val clip = intent.clipData + if (clip != null && clip.itemCount > 0) { + val itemUri = clip.getItemAt(0).uri + if (itemUri != null) return itemUri + } + getParcelableUriExtra(intent, Intent.EXTRA_STREAM)?.let { return it } + for (key in romIntentExtraKeys) { + getParcelableUriExtra(intent, key)?.let { return it } + val text = intent.getStringExtra(key) + if (!text.isNullOrBlank()) { + val parsed = Uri.parse(text) + if (!parsed.toString().isNullOrBlank()) return parsed + } + } + return null + } + + private fun getParcelableUriExtra(intent: Intent, key: String): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(key, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(key) as? Uri + } + } + + private fun resolveFileFromUri(uri: Uri): File? { + val path = uri.path ?: return null + return if (uri.scheme == null || uri.scheme.equals("file", ignoreCase = true)) { + File(path) + } else { + null + } + } + + private fun resolveRomNameAndSize(uri: Uri, file: File?): Pair { + if (file != null) { + val size = if (file.exists()) file.length() else null + return file.name to size + } + val doc = DocumentFile.fromSingleUri(this, uri) + val name = doc?.name ?: uri.lastPathSegment.orEmpty() + val size = doc?.length()?.takeIf { it > 0 } + return name to size + } + + private fun normalizeRomBaseName(name: String): String { + val trimmed = name.trim() + if (trimmed.isBlank()) return "" + val dot = trimmed.lastIndexOf('.') + return if (dot > 0) trimmed.substring(0, dot) else trimmed + } + + private fun extractGameNameExtra(intent: Intent): String? { + for (key in romNameExtraKeys) { + val value = intent.getStringExtra(key) + if (!value.isNullOrBlank()) return value.trim() + } + return null + } + + private fun updateStatus(text: String) { + if (::statusText.isInitialized) { + statusText.text = text + } + } + + private fun maybePersistReadPermission(intent: Intent, uri: Uri) { + if (!uri.scheme.equals("content", ignoreCase = true)) return + if (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION == 0) return + try { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: SecurityException) { + } + } + + private fun scanZipFilesInDir(dir: File): Map { + val files = dir.listFiles() ?: return emptyMap() + val map = HashMap(files.size) + for (child in files) { + if (!child.isFile) continue + val name = child.name + if (!name.endsWith(".zip", ignoreCase = true)) continue + map[name.substring(0, name.length - 4)] = child + } + return map + } + + private fun copyFileToCacheIfNeeded(file: File, outFile: File): Boolean { + if (!file.exists() || !file.isFile) return false + outFile.parentFile?.mkdirs() + if (outFile.exists() && outFile.length() == file.length()) { + return true + } + return try { + file.inputStream().use { ins -> + outFile.outputStream().use { outs -> + ins.copyTo(outs) + } + } + true + } catch (_: Throwable) { + false + } + } + + private fun copyUriToCacheIfNeeded(uri: Uri, expectedSize: Long?, outFile: File): Boolean { + outFile.parentFile?.mkdirs() + if (outFile.exists() && expectedSize != null && expectedSize > 0 && outFile.length() == expectedSize) { + return true + } + return try { + val input = contentResolver.openInputStream(uri) ?: return false + input.use { ins -> + outFile.outputStream().use { outs -> + ins.copyTo(outs) + } + } + true + } catch (_: Throwable) { + false + } + } + + private fun handleRomLaunchIntent(intent: Intent, info: RomLaunchInfo) { + if (launchingRomIntent) return + launchingRomIntent = true + + loadPrefs() + maybePersistReadPermission(intent, info.uri) + if (games.isEmpty()) { + games = GameXml.parseGamesXmlFromAssets(this) + } + + val explicitName = extractGameNameExtra(intent) + val explicitDef = + explicitName?.let { name -> + games.firstOrNull { it.name.equals(name, ignoreCase = true) } + } + val baseDef = games.firstOrNull { it.name.equals(info.baseName, ignoreCase = true) } + val gameDef = explicitDef ?: baseDef + val gameName = gameDef?.name ?: info.baseName + val displayName = gameDef?.displayName ?: gameName + updateStatus("Preparing $displayName...") + + thread(name = "Super3IntentLaunch") { + try { + val internalRoot = internalUserRoot() + val userUri = userTreeUri + if (userUri != null) { + UserDataSync.syncFromTreeIntoInternal(this, userUri, internalRoot, UserDataSync.DIRS_GAME_SYNC) + } + + AssetInstaller.ensureInstalled(this, internalRoot) + applyVideoSettingsToIni(internalRoot, loadVideoSettings()) + applyTimingDefaultToIni(internalRoot, loadTimingPref()) + + val cacheDir = File(internalRoot, "romcache") + val required = gameDef?.let { resolveRequiredRomZips(it) } ?: listOf(info.baseName) + val docMap = if (gamesTreeUri != null) scanZipDocs(gamesTreeUri) else emptyMap() + val dirMap = info.file?.parentFile?.let { scanZipFilesInDir(it) } ?: emptyMap() + val docMapLower = docMap.mapKeys { it.key.lowercase() } + val dirMapLower = dirMap.mapKeys { it.key.lowercase() } + + val mainOut = File(cacheDir, "${info.baseName}.zip") + val mainOk = + if (info.file != null) { + copyFileToCacheIfNeeded(info.file, mainOut) + } else { + copyUriToCacheIfNeeded(info.uri, info.size, mainOut) + } + if (!mainOk) { + runOnUiThread { + val msg = "Failed to open ${info.displayName}" + updateStatus(msg) + Toast.makeText(this, msg, Toast.LENGTH_LONG).show() + } + return@thread + } + + val missing = ArrayList() + val mainKey = info.baseName.lowercase() + for (zipBase in required) { + val key = zipBase.lowercase() + if (key == mainKey) continue + val outFile = File(cacheDir, "$zipBase.zip") + val ok = + when { + dirMapLower.containsKey(key) -> copyFileToCacheIfNeeded(dirMapLower[key]!!, outFile) + docMapLower.containsKey(key) -> copyDocToCacheIfNeeded(docMapLower[key]!!, outFile) + else -> false + } + if (!ok) missing.add(zipBase) + } + + if (missing.isNotEmpty()) { + runOnUiThread { + val msg = "Missing required ZIP(s): ${missing.joinToString(", ")}" + updateStatus(msg) + Toast.makeText(this, msg, Toast.LENGTH_LONG).show() + } + return@thread + } + + val romPath = mainOut.absolutePath + val gamesXmlPath = File(internalRoot, "Config/Games.xml").absolutePath + val userDataRoot = internalRoot.absolutePath + + runOnUiThread { + val launch = + Intent(this, Super3Activity::class.java).apply { + putExtra("romZipPath", romPath) + putExtra("gameName", gameName) + putExtra("gamesXmlPath", gamesXmlPath) + putExtra("userDataRoot", userDataRoot) + } + startActivity(launch) + } + } finally { + launchingRomIntent = false + } + } + } + private fun refreshUi() { gamesFolderText.text = "Games folder: ${gamesTreeUri?.toString() ?: "Not set"}" userFolderText.text = "Data folder: ${userTreeUri?.toString() ?: "Not set"}"