detect if not xiso red text to convert to xiso - can now delete games in app - long press

This commit is contained in:
izzy2lost
2026-03-13 08:45:56 -04:00
parent 7e56ff7b31
commit 20d467c137
5 changed files with 233 additions and 15 deletions
@@ -3,12 +3,16 @@ package com.izzy2lost.x1box
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -45,13 +49,37 @@ class GameLibraryActivity : AppCompatActivity() {
const val EXTRA_RESTART_LAST_GAME = "com.izzy2lost.x1box.extra.RESTART_LAST_GAME"
private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12
private const val TOTAL_SNAPSHOT_SLOTS = 10
private const val XDVDFS_SECTOR_SIZE = 2048L
private const val XDVDFS_VOLUME_DESCRIPTOR_OFFSET = 32L * XDVDFS_SECTOR_SIZE
private const val XDVDFS_VOLUME_DESCRIPTOR_SIZE = 0x800
private const val XDVDFS_TRAILING_MAGIC_OFFSET = 0x7EC
private const val XDVDFS_XGD1_OFFSET = 405_798_912L
private const val XDVDFS_XGD2_OFFSET = 265_879_552L
private const val XDVDFS_XGD3_OFFSET = 34_078_720L
private val XDVDFS_VOLUME_MAGIC = "MICROSOFT*XBOX*MEDIA".toByteArray(Charsets.US_ASCII)
private val XDVDFS_IMAGE_OFFSETS =
longArrayOf(0L, XDVDFS_XGD1_OFFSET, XDVDFS_XGD2_OFFSET, XDVDFS_XGD3_OFFSET)
}
private enum class DiscImageFormat {
OTHER,
XISO,
REGULAR_ISO,
UNKNOWN_ISO,
}
private enum class GameContextAction {
SET_CUSTOM_COVER,
REMOVE_CUSTOM_COVER,
DELETE_GAME,
}
private data class GameEntry(
val title: String,
val uri: Uri,
val relativePath: String,
val sizeBytes: Long
val sizeBytes: Long,
val discImageFormat: DiscImageFormat,
)
private data class CoverEntry(
@@ -529,7 +557,7 @@ class GameLibraryActivity : AppCompatActivity() {
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))
sizeText.text = buildGameSizeText(game)
pathText.text = getString(R.string.library_game_path, game.relativePath)
item.setOnClickListener { launchGame(game) }
@@ -572,7 +600,7 @@ class GameLibraryActivity : AppCompatActivity() {
val coverImage = item.findViewById<ImageView>(R.id.game_cover_image)
nameText.text = game.title
sizeText.text = getString(R.string.library_game_size, formatSize(game.sizeBytes))
sizeText.text = buildGameSizeText(game)
item.setOnClickListener { launchGame(game) }
item.setOnLongClickListener { showGameContextMenu(game); true }
bindCoverArt(coverImage, game)
@@ -1074,9 +1102,40 @@ class GameLibraryActivity : AppCompatActivity() {
}
}
private fun buildGameSizeText(game: GameEntry): CharSequence {
val sizeLabel = getString(R.string.library_game_size, formatSize(game.sizeBytes))
if (game.discImageFormat != DiscImageFormat.REGULAR_ISO) {
return sizeLabel
}
return SpannableStringBuilder(sizeLabel).apply {
append(" ")
val badgeStart = length
append(getString(R.string.library_regular_iso_badge))
setSpan(
ForegroundColorSpan(getColor(R.color.xemu_warning)),
badgeStart,
length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
setSpan(
StyleSpan(Typeface.BOLD),
badgeStart,
length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
private fun isConvertibleIso(game: GameEntry): Boolean {
val lower = game.relativePath.lowercase(Locale.ROOT)
return lower.endsWith(".iso") && !lower.endsWith(".xiso.iso")
return when (game.discImageFormat) {
DiscImageFormat.REGULAR_ISO -> true
DiscImageFormat.UNKNOWN_ISO -> {
val lower = game.relativePath.lowercase(Locale.ROOT)
lower.endsWith(".iso") && !lower.endsWith(".xiso.iso")
}
else -> false
}
}
private fun buildXisoFileName(sourceName: String): String {
@@ -1126,7 +1185,8 @@ class GameLibraryActivity : AppCompatActivity() {
title = toGameTitle(name),
uri = child.uri,
relativePath = prefix + name,
sizeBytes = child.length()
sizeBytes = child.length(),
discImageFormat = detectDiscImageFormat(child.uri, name),
)
)
}
@@ -1136,6 +1196,79 @@ class GameLibraryActivity : AppCompatActivity() {
return games
}
private fun detectDiscImageFormat(uri: Uri, fileName: String): DiscImageFormat {
val lower = fileName.lowercase(Locale.ROOT)
if (!isDiscImageFormatDetectable(lower)) {
return DiscImageFormat.OTHER
}
return try {
contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
readDiscImageFormat(descriptor)
} ?: DiscImageFormat.UNKNOWN_ISO
} catch (_: Exception) {
DiscImageFormat.UNKNOWN_ISO
}
}
private fun isDiscImageFormatDetectable(lowerFileName: String): Boolean {
return lowerFileName.endsWith(".iso") || lowerFileName.endsWith(".xiso")
}
private fun readDiscImageFormat(descriptor: ParcelFileDescriptor): DiscImageFormat {
FileInputStream(descriptor.fileDescriptor).use { input ->
val channel = input.channel
val imageSize = channel.size()
val header = ByteBuffer.allocate(XDVDFS_VOLUME_DESCRIPTOR_SIZE.toInt())
for (offset in XDVDFS_IMAGE_OFFSETS) {
val descriptorOffset = offset + XDVDFS_VOLUME_DESCRIPTOR_OFFSET
if (imageSize < descriptorOffset + XDVDFS_VOLUME_DESCRIPTOR_SIZE) {
continue
}
header.clear()
channel.position(descriptorOffset)
while (header.hasRemaining()) {
val read = channel.read(header)
if (read <= 0) {
break
}
}
if (header.hasRemaining()) {
continue
}
if (isValidXdvdfsVolumeDescriptor(header.array())) {
return if (offset == 0L) DiscImageFormat.XISO else DiscImageFormat.REGULAR_ISO
}
}
}
return DiscImageFormat.UNKNOWN_ISO
}
private fun isValidXdvdfsVolumeDescriptor(header: ByteArray): Boolean {
return matchesXdvdfsMagic(header, 0) &&
matchesXdvdfsMagic(header, XDVDFS_TRAILING_MAGIC_OFFSET)
}
private fun matchesXdvdfsMagic(buffer: ByteArray, startIndex: Int): Boolean {
if (buffer.size < startIndex + XDVDFS_VOLUME_MAGIC.size) {
return false
}
for (index in XDVDFS_VOLUME_MAGIC.indices) {
if (buffer[startIndex + index] != XDVDFS_VOLUME_MAGIC[index]) {
return false
}
}
return true
}
private fun isSupportedGame(name: String): Boolean {
val lower = name.lowercase(Locale.ROOT)
if (lower.endsWith(".xiso.iso")) {
@@ -1292,22 +1425,99 @@ class GameLibraryActivity : AppCompatActivity() {
}
}
private fun confirmDeleteGame(game: GameEntry) {
val folderUri = gamesFolderUri
if (folderUri == null) {
Toast.makeText(this, getString(R.string.library_no_folder), Toast.LENGTH_SHORT).show()
return
}
if (!hasPersistedWritePermission(folderUri)) {
Toast.makeText(
this,
getString(R.string.library_delete_write_permission),
Toast.LENGTH_LONG
).show()
pickGamesFolder.launch(folderUri)
return
}
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(R.string.library_delete_title)
.setMessage(getString(R.string.library_delete_confirm_message, game.title))
.setPositiveButton(R.string.library_delete_action) { _, _ ->
deleteGame(game)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteGame(game: GameEntry) {
if (isConvertingIso) {
Toast.makeText(this, getString(R.string.library_convert_busy), Toast.LENGTH_SHORT).show()
return
}
val folderUri = gamesFolderUri
if (folderUri == null) {
Toast.makeText(this, getString(R.string.library_no_folder), Toast.LENGTH_SHORT).show()
return
}
val root = DocumentFile.fromTreeUri(this, folderUri)
val parent = root?.let { resolveParentDirectory(it, game.relativePath) }
val sourceName = game.relativePath.substringAfterLast('/')
val target = parent?.findFile(sourceName)
if (target == null || !target.isFile) {
Toast.makeText(this, getString(R.string.library_delete_failed, game.title), Toast.LENGTH_LONG).show()
loadGames()
return
}
val deleted = runCatching { target.delete() }.getOrDefault(false)
if (!deleted) {
Toast.makeText(this, getString(R.string.library_delete_failed, game.title), Toast.LENGTH_LONG).show()
return
}
val customCover = getCustomCoverFile(game)
if (customCover.exists()) {
customCover.delete()
}
boxArtCache.remove(normalizeCoverKey(game.title))
boxArtMisses.remove(normalizeCoverKey(game.title))
Toast.makeText(this, getString(R.string.library_delete_success, game.title), Toast.LENGTH_SHORT).show()
loadGames()
}
private fun showGameContextMenu(game: GameEntry) {
val hasCustomCover = getCustomCoverFile(game).exists()
val options = buildList {
add(getString(R.string.library_custom_cover_set_option))
if (hasCustomCover) add(getString(R.string.library_custom_cover_remove_option))
val actions = buildList {
add(GameContextAction.SET_CUSTOM_COVER)
if (hasCustomCover) {
add(GameContextAction.REMOVE_CUSTOM_COVER)
}
add(GameContextAction.DELETE_GAME)
}
val options = actions.map { action ->
when (action) {
GameContextAction.SET_CUSTOM_COVER -> getString(R.string.library_custom_cover_set_option)
GameContextAction.REMOVE_CUSTOM_COVER -> getString(R.string.library_custom_cover_remove_option)
GameContextAction.DELETE_GAME -> getString(R.string.library_delete_option)
}
}.toTypedArray()
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(game.title)
.setItems(options) { _, which ->
when {
which == 0 -> {
when (actions[which]) {
GameContextAction.SET_CUSTOM_COVER -> {
pendingCustomCoverGame = game
pickCustomCover.launch("image/*")
}
which == 1 && hasCustomCover -> removeCustomCover(game)
GameContextAction.REMOVE_CUSTOM_COVER -> removeCustomCover(game)
GameContextAction.DELETE_GAME -> confirmDeleteGame(game)
}
}
.setNegativeButton(android.R.string.cancel, null)
@@ -1325,4 +1535,3 @@ class GameLibraryActivity : AppCompatActivity() {
}
}
}
@@ -48,7 +48,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted"
app:layout_constraintBottom_toBottomOf="parent"
@@ -48,7 +48,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
@@ -4,6 +4,7 @@
<color name="xemu_surface_variant">#141914</color>
<color name="xemu_text">#E8F5E9</color>
<color name="xemu_text_muted">#A3B7A3</color>
<color name="xemu_warning">#FF6B6B</color>
<color name="xemu_green">#41B14D</color>
<color name="xemu_green_dark">#1E7A2E</color>
<color name="xemu_outline">#263126</color>
@@ -56,7 +56,15 @@
<string name="library_convert_copy_output_failed">Failed to copy converted XISO back to games folder.</string>
<string name="library_convert_resolve_failed">Could not resolve destination folder for selected game.</string>
<string name="library_restart_failed">Unable to restart. The selected game is no longer accessible.</string>
<string name="library_delete_option">Delete Game</string>
<string name="library_delete_title">Delete Game</string>
<string name="library_delete_action">Delete</string>
<string name="library_delete_confirm_message">Are you sure you want to delete %1$s?</string>
<string name="library_delete_success">Deleted %1$s</string>
<string name="library_delete_failed">Failed to delete %1$s</string>
<string name="library_delete_write_permission">Games folder is read-only. Re-select it to allow deleting files.</string>
<string name="library_game_size">Size: %1$s</string>
<string name="library_regular_iso_badge">! Convert to XISO</string>
<string name="library_game_path">Path: %1$s</string>
<string name="library_open_game">Open</string>
<string name="library_about_button_desc">About X1 BOX</string>