diff --git a/android/app/src/main/cpp/xemu_hud_stub.c b/android/app/src/main/cpp/xemu_hud_stub.c index d05bd2293e..bc494df92f 100644 --- a/android/app/src/main/cpp/xemu_hud_stub.c +++ b/android/app/src/main/cpp/xemu_hud_stub.c @@ -1,6 +1,8 @@ #include "qemu/osdep.h" #include "xui/xemu-hud.h" +extern void xemu_android_process_snapshot_request(void); + void xemu_hud_init(SDL_Window *window, void *sdl_gl_context) { (void)window; @@ -13,6 +15,7 @@ void xemu_hud_cleanup(void) void xemu_hud_render(void) { + xemu_android_process_snapshot_request(); } void xemu_hud_process_sdl_events(SDL_Event *event) diff --git a/android/app/src/main/cpp/xemu_snapshots_stub.c b/android/app/src/main/cpp/xemu_snapshots_stub.c index 68cbad544c..016e1d18de 100644 --- a/android/app/src/main/cpp/xemu_snapshots_stub.c +++ b/android/app/src/main/cpp/xemu_snapshots_stub.c @@ -1,13 +1,292 @@ +#include "qemu/osdep.h" #include "ui/xemu-snapshots.h" #include "qapi/error.h" +#include "migration/snapshot.h" +#include "migration/qemu-file.h" +#include "system/runstate.h" +#include "xemu-xbe.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include const char **g_snapshot_shortcut_index_key_map[] = { NULL }; +static bool xemu_snapshots_dirty = true; +static GLuint g_snapshot_display_tex = 0; +static bool g_snapshot_display_flip = false; + +#define SNAPSHOT_PREVIEW_WIDTH 320 +#define SNAPSHOT_PREVIEW_HEIGHT 240 +#define SNAPSHOT_PREVIEW_VERSION 1 + +#define SNAP_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "xemu-android", __VA_ARGS__) +#define SNAP_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "xemu-android", __VA_ARGS__) + +typedef struct SnapshotPreviewHeader { + char magic[4]; + uint16_t version; + uint16_t width; + uint16_t height; + uint16_t channels; +} SnapshotPreviewHeader; + +static void sanitize_snapshot_name(const char *in, char *out, size_t out_len) +{ + size_t j = 0; + + if (!out || out_len == 0) { + return; + } + + if (!in || !in[0]) { + g_strlcpy(out, "snapshot", out_len); + return; + } + + for (size_t i = 0; in[i] && j + 1 < out_len; ++i) { + unsigned char c = (unsigned char)in[i]; + if (g_ascii_isalnum(c) || c == '_' || c == '-') { + out[j++] = (char)c; + } else { + out[j++] = '_'; + } + } + + if (j == 0) { + g_strlcpy(out, "snapshot", out_len); + } else { + out[j] = '\0'; + } +} + +static char *get_snapshot_preview_dir(void) +{ + const char *base = SDL_AndroidGetInternalStoragePath(); + char *dir; + + if (!base || !base[0]) { + return NULL; + } + + dir = g_strdup_printf("%s/x1box/snapshots", base); + if (g_mkdir_with_parents(dir, 0700) != 0) { + SNAP_LOGW("failed to create snapshot preview dir: %s", dir); + g_free(dir); + return NULL; + } + + return dir; +} + +static char *get_snapshot_title(void) +{ + struct xbe *xbe_data = xemu_get_xbe_info(); + char *title = NULL; + + if (xbe_data && xbe_data->cert) { + glong items_written = 0; + title = g_utf16_to_utf8((const gunichar2 *)xbe_data->cert->m_title_name, + 40, NULL, &items_written, NULL); + if (title) { + g_strstrip(title); + if (title[0]) { + return title; + } + g_free(title); + title = NULL; + } + } + + return g_strdup("Unknown Game"); +} + +static bool capture_snapshot_thumbnail(uint8_t **pixels_out, size_t *pixels_size_out) +{ + GLint viewport[4] = { 0, 0, 0, 0 }; + GLint prev_pack_alignment = 4; + uint8_t *src_pixels = NULL; + uint8_t *dst_pixels = NULL; + bool ok = false; + + if (!pixels_out || !pixels_size_out) { + return false; + } + + *pixels_out = NULL; + *pixels_size_out = 0; + + if (!SDL_GL_GetCurrentContext() || g_snapshot_display_tex == 0) { + return false; + } + + glGetIntegerv(GL_VIEWPORT, viewport); + + if (viewport[2] <= 0 || viewport[3] <= 0) { + return false; + } + + (void)g_snapshot_display_flip; + + { + const int src_w = viewport[2]; + const int src_h = viewport[3]; + const size_t src_bytes = (size_t)src_w * (size_t)src_h * 4; + const size_t dst_bytes = (size_t)SNAPSHOT_PREVIEW_WIDTH * + (size_t)SNAPSHOT_PREVIEW_HEIGHT * 4; + + src_pixels = g_malloc(src_bytes); + dst_pixels = g_malloc(dst_bytes); + + glGetIntegerv(GL_PACK_ALIGNMENT, &prev_pack_alignment); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(viewport[0], viewport[1], src_w, src_h, + GL_RGBA, GL_UNSIGNED_BYTE, src_pixels); + glPixelStorei(GL_PACK_ALIGNMENT, prev_pack_alignment); + if (glGetError() != GL_NO_ERROR) { + goto cleanup; + } + + for (int y = 0; y < SNAPSHOT_PREVIEW_HEIGHT; ++y) { + const int src_y = (int)(((int64_t)y * src_h) / SNAPSHOT_PREVIEW_HEIGHT); + for (int x = 0; x < SNAPSHOT_PREVIEW_WIDTH; ++x) { + const int src_x = (int)(((int64_t)x * src_w) / SNAPSHOT_PREVIEW_WIDTH); + const size_t src_off = ((size_t)src_y * (size_t)src_w + (size_t)src_x) * 4; + const size_t dst_off = + ((size_t)y * (size_t)SNAPSHOT_PREVIEW_WIDTH + (size_t)x) * 4; + memcpy(dst_pixels + dst_off, src_pixels + src_off, 4); + } + } + } + + *pixels_out = dst_pixels; + *pixels_size_out = (size_t)SNAPSHOT_PREVIEW_WIDTH * + (size_t)SNAPSHOT_PREVIEW_HEIGHT * 4; + dst_pixels = NULL; + ok = true; + +cleanup: + g_free(src_pixels); + g_free(dst_pixels); + return ok; +} + +static void write_snapshot_preview_sidecar(const char *vm_name) +{ + char safe_name[128]; + char *dir = NULL; + char *thumb_path = NULL; + char *title_path = NULL; + char *title = NULL; + uint8_t *pixels = NULL; + size_t pixels_size = 0; + + FILE *title_file = NULL; + FILE *thumb_file = NULL; + + if (!vm_name || !vm_name[0]) { + return; + } + + sanitize_snapshot_name(vm_name, safe_name, sizeof(safe_name)); + + dir = get_snapshot_preview_dir(); + if (!dir) { + return; + } + + thumb_path = g_strdup_printf("%s/%s.thm", dir, safe_name); + title_path = g_strdup_printf("%s/%s.title", dir, safe_name); + + title = get_snapshot_title(); + if (title) { + title_file = fopen(title_path, "wb"); + if (title_file) { + fwrite(title, 1, strlen(title), title_file); + fclose(title_file); + title_file = NULL; + } + } + + if (!capture_snapshot_thumbnail(&pixels, &pixels_size)) { + SNAP_LOGW("snapshot preview capture failed for %s", vm_name); + goto cleanup; + } + + { + SnapshotPreviewHeader header; + memcpy(header.magic, "X1TH", 4); + header.version = SNAPSHOT_PREVIEW_VERSION; + header.width = SNAPSHOT_PREVIEW_WIDTH; + header.height = SNAPSHOT_PREVIEW_HEIGHT; + header.channels = 4; + + thumb_file = fopen(thumb_path, "wb"); + if (!thumb_file) { + SNAP_LOGW("failed to open snapshot preview file: %s", thumb_path); + goto cleanup; + } + + if (fwrite(&header, sizeof(header), 1, thumb_file) != 1 || + fwrite(pixels, 1, pixels_size, thumb_file) != pixels_size) { + SNAP_LOGW("failed writing snapshot preview: %s", thumb_path); + } + + fclose(thumb_file); + thumb_file = NULL; + } + +cleanup: + if (thumb_file) { + fclose(thumb_file); + } + if (title_file) { + fclose(title_file); + } + g_free(pixels); + g_free(title); + g_free(title_path); + g_free(thumb_path); + g_free(dir); +} + char *xemu_get_currently_loaded_disc_path(void) { return NULL; } +void xemu_snapshots_save(const char *vm_name, Error **err) +{ + save_snapshot(vm_name, true, NULL, false, NULL, err); + xemu_snapshots_dirty = true; +} + +void xemu_snapshots_load(const char *vm_name, Error **err) +{ + bool was_running = runstate_is_running(); + vm_stop(RUN_STATE_RESTORE_VM); + if (load_snapshot(vm_name, NULL, false, NULL, err) && was_running) { + vm_start(); + } +} + +void xemu_snapshots_delete(const char *vm_name, Error **err) +{ + delete_snapshot(vm_name, false, NULL, err); + xemu_snapshots_dirty = true; +} + +void xemu_snapshots_mark_dirty(void) +{ + xemu_snapshots_dirty = true; +} + int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data, Error **err) { @@ -21,49 +300,56 @@ int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data, return 0; } -void xemu_snapshots_load(const char *vm_name, Error **err) -{ - (void)vm_name; - if (err) { - *err = NULL; - } -} - -void xemu_snapshots_save(const char *vm_name, Error **err) -{ - (void)vm_name; - if (err) { - *err = NULL; - } -} - -void xemu_snapshots_delete(const char *vm_name, Error **err) -{ - (void)vm_name; - if (err) { - *err = NULL; - } -} - void xemu_snapshots_save_extra_data(QEMUFile *f) { - (void)f; + char *title = get_snapshot_title(); + size_t title_size = title ? strlen(title) : 0; + + if (title_size > 255) { + title_size = 255; + } + + qemu_put_be32(f, XEMU_SNAPSHOT_DATA_MAGIC); + qemu_put_be32(f, XEMU_SNAPSHOT_DATA_VERSION); + qemu_put_be32(f, 4 + 1 + title_size + 4); + qemu_put_be32(f, 0); + qemu_put_byte(f, (uint8_t)title_size); + if (title_size) { + qemu_put_buffer(f, (const uint8_t *)title, title_size); + } + qemu_put_be32(f, 0); + + g_free(title); + xemu_snapshots_dirty = true; } bool xemu_snapshots_offset_extra_data(QEMUFile *f) { - (void)f; - return false; -} + unsigned int v; + uint32_t size; -void xemu_snapshots_mark_dirty(void) -{ + v = qemu_get_be32(f); + if (v != XEMU_SNAPSHOT_DATA_MAGIC) { + qemu_file_skip(f, -4); + return true; + } + + qemu_get_be32(f); + size = qemu_get_be32(f); + + { + void *buf = g_malloc(size); + qemu_get_buffer(f, buf, size); + g_free(buf); + } + + return true; } void xemu_snapshots_set_framebuffer_texture(GLuint tex, bool flip) { - (void)tex; - (void)flip; + g_snapshot_display_tex = tex; + g_snapshot_display_flip = flip; } bool xemu_snapshots_load_png_to_texture(GLuint tex, void *buf, size_t size) @@ -81,3 +367,96 @@ void *xemu_snapshots_create_framebuffer_thumbnail_png(size_t *size) } return NULL; } + +typedef enum SnapOpType { + SNAP_NONE, + SNAP_SAVE, + SNAP_LOAD, +} SnapOpType; + +static struct { + pthread_mutex_t lock; + pthread_cond_t cond; + SnapOpType type; + char name[128]; + bool pending; + bool done; + bool success; +} g_snap_req = { + .lock = PTHREAD_MUTEX_INITIALIZER, + .cond = PTHREAD_COND_INITIALIZER, +}; + +void xemu_android_process_snapshot_request(void) +{ + if (pthread_mutex_trylock(&g_snap_req.lock) != 0) { + return; + } + + if (!g_snap_req.pending) { + pthread_mutex_unlock(&g_snap_req.lock); + return; + } + + Error *err = NULL; + + if (g_snap_req.type == SNAP_SAVE) { + xemu_snapshots_save(g_snap_req.name, &err); + if (!err) { + write_snapshot_preview_sidecar(g_snap_req.name); + } + } else if (g_snap_req.type == SNAP_LOAD) { + xemu_snapshots_load(g_snap_req.name, &err); + } + + if (err) { + SNAP_LOGW("snapshot op failed: %s", error_get_pretty(err)); + error_free(err); + g_snap_req.success = false; + } else { + g_snap_req.success = true; + } + + g_snap_req.pending = false; + g_snap_req.done = true; + pthread_cond_signal(&g_snap_req.cond); + pthread_mutex_unlock(&g_snap_req.lock); +} + +static jboolean dispatch_snapshot(JNIEnv *env, jstring jname, SnapOpType type) +{ + const char *name = (*env)->GetStringUTFChars(env, jname, NULL); + + pthread_mutex_lock(&g_snap_req.lock); + g_snap_req.type = type; + g_snap_req.pending = true; + g_snap_req.done = false; + strncpy(g_snap_req.name, name, sizeof(g_snap_req.name) - 1); + g_snap_req.name[sizeof(g_snap_req.name) - 1] = '\0'; + (*env)->ReleaseStringUTFChars(env, jname, name); + + while (!g_snap_req.done) { + pthread_cond_wait(&g_snap_req.cond, &g_snap_req.lock); + } + + jboolean ok = (jboolean)g_snap_req.success; + g_snap_req.type = SNAP_NONE; + pthread_mutex_unlock(&g_snap_req.lock); + return ok; +} + +JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_x1box_MainActivity_nativeSaveSnapshot( + JNIEnv *env, jobject obj, jstring name) +{ + (void)obj; + return dispatch_snapshot(env, name, SNAP_SAVE); +} + +JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_x1box_MainActivity_nativeLoadSnapshot( + JNIEnv *env, jobject obj, jstring name) +{ + (void)obj; + return dispatch_snapshot(env, name, SNAP_LOAD); +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt index 1513d2c370..fc3423e806 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt @@ -2,6 +2,7 @@ package com.izzy2lost.x1box import android.content.Intent import android.content.res.Configuration +import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.text.SpannableStringBuilder @@ -10,9 +11,12 @@ import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout +import android.widget.ListView import android.widget.ProgressBar import android.widget.Space import android.widget.TextView @@ -30,6 +34,8 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.net.URLEncoder +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.ArrayDeque import java.util.Locale import java.util.concurrent.ConcurrentHashMap @@ -37,6 +43,8 @@ import java.util.concurrent.ConcurrentHashMap class GameLibraryActivity : AppCompatActivity() { companion object { 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 data class GameEntry( @@ -53,6 +61,13 @@ class GameLibraryActivity : AppCompatActivity() { val url: String ) + private data class SnapshotSlotPreview( + val slot: Int, + val slotLabel: String, + val gameTitle: String, + val thumbnail: Bitmap?, + ) + private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) } private val gameExts = setOf("iso", "xiso", "cso", "cci") private val titleStopWords = setOf("the", "a", "an", "and", "of", "for", "in", "on", "to") @@ -72,6 +87,7 @@ class GameLibraryActivity : AppCompatActivity() { private lateinit var gamesGridContainer: LinearLayout private lateinit var btnChangeFolder: MaterialButton private lateinit var btnSettings: MaterialButton + private lateinit var btnSnapshots: MaterialButton private lateinit var btnConvertIso: MaterialButton private lateinit var btnAbout: ImageButton private lateinit var viewModeToggle: MaterialButtonToggleGroup @@ -112,6 +128,7 @@ class GameLibraryActivity : AppCompatActivity() { gamesGridContainer = findViewById(R.id.library_games_grid_container) btnChangeFolder = findViewById(R.id.btn_change_games_folder) btnSettings = findViewById(R.id.btn_settings) + btnSnapshots = findViewById(R.id.btn_snapshots) btnConvertIso = findViewById(R.id.btn_convert_iso) btnAbout = findViewById(R.id.btn_library_about) viewModeToggle = findViewById(R.id.library_view_mode_toggle) @@ -135,6 +152,9 @@ class GameLibraryActivity : AppCompatActivity() { btnSettings.setOnClickListener { startActivity(Intent(this, SettingsActivity::class.java)) } + btnSnapshots.setOnClickListener { + showSnapshotStartupPicker() + } btnConvertIso.setOnClickListener { showIsoConversionPicker() } @@ -216,6 +236,226 @@ class GameLibraryActivity : AppCompatActivity() { finish() } + private fun slotName(slot: Int) = "android_slot_$slot" + + private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots") + + private fun snapshotPreviewDirs(): List { + val dirs = ArrayList(2) + dirs.add(snapshotPreviewDir()) + getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) } + return dirs.distinctBy { it.absolutePath } + } + + private fun slotNameAliases(slot: Int): List { + val aliases = linkedSetOf( + slotName(slot), + "slot_$slot", + "slot$slot", + "snapshot_$slot", + ) + return aliases.toList() + } + + private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? { + for (dir in snapshotPreviewDirs()) { + for (name in slotNameAliases(slot)) { + val file = File(dir, "$name.$extension") + if (file.isFile) { + return file + } + } + } + return null + } + + private fun extractDisplayName(rawName: String?): String? { + if (rawName.isNullOrBlank()) { + return null + } + val decoded = Uri.decode(rawName) + val leaf = decoded.substringAfterLast('/').substringAfterLast(':') + if (leaf.isBlank()) { + return null + } + val stem = leaf.substringBeforeLast('.', leaf).trim() + return stem.takeIf { it.isNotEmpty() } + } + + private fun fallbackCurrentGameName(): String { + val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name }) + if (!pathName.isNullOrEmpty()) { + return pathName + } + val uriName = extractDisplayName(prefs.getString("dvdUri", null)) + if (!uriName.isNullOrEmpty()) { + return uriName + } + return getString(R.string.snapshot_unknown_game) + } + + private fun readSnapshotGameTitle(slot: Int): String { + val title = runCatching { + val file = resolveSnapshotPreviewFile(slot, "title") + if (file != null && file.exists()) { + file.readText(Charsets.UTF_8).trim() + } else { + "" + } + }.getOrDefault("") + + if (title.isNotEmpty()) { + return title + } + + return if (resolveSnapshotPreviewFile(slot, "thm") != null) { + fallbackCurrentGameName() + } else { + getString(R.string.snapshot_empty_slot) + } + } + + private fun decodeSnapshotThumbnail(slot: Int): Bitmap? { + val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null + val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null + if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) { + return null + } + + if (bytes[0] != 'X'.code.toByte() || + bytes[1] != '1'.code.toByte() || + bytes[2] != 'T'.code.toByte() || + bytes[3] != 'H'.code.toByte()) { + return null + } + + val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN) + val version = header.short.toInt() and 0xFFFF + val width = header.short.toInt() and 0xFFFF + val height = header.short.toInt() and 0xFFFF + val channels = header.short.toInt() and 0xFFFF + + if (version != 1 || channels != 4 || width <= 0 || height <= 0) { + return null + } + + val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong() + if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) { + return null + } + + val pixelBytes = pixelBytesLong.toInt() + if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) { + return null + } + + val pixels = IntArray(width * height) + var src = SNAPSHOT_PREVIEW_HEADER_SIZE + for (y in 0 until height) { + val dstRow = (height - 1 - y) * width + for (x in 0 until width) { + val r = bytes[src].toInt() and 0xFF + val g = bytes[src + 1].toInt() and 0xFF + val b = bytes[src + 2].toInt() and 0xFF + pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + src += 4 + } + } + + return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + private fun loadSnapshotSlotPreviews(): List { + return (1..TOTAL_SNAPSHOT_SLOTS).map { slot -> + SnapshotSlotPreview( + slot = slot, + slotLabel = getString(R.string.snapshot_slot_label, slot), + gameTitle = readSnapshotGameTitle(slot), + thumbnail = decodeSnapshotThumbnail(slot), + ) + } + } + + private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) { + val bitmap = preview.thumbnail ?: return + val image = ImageView(this).apply { + setImageBitmap(bitmap) + adjustViewBounds = true + scaleType = ImageView.ScaleType.FIT_CENTER + setPadding(16, 16, 16, 16) + } + + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle)) + .setView(image) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun launchMainActivityWithSnapshot(slot: Int) { + val intent = Intent(this, MainActivity::class.java).apply { + putExtra(MainActivity.EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, slot) + } + startActivity(intent) + finish() + } + + private fun showSnapshotStartupPicker() { + val previews = loadSnapshotSlotPreviews() + val listView = ListView(this) + lateinit var dialog: androidx.appcompat.app.AlertDialog + + val adapter = object : BaseAdapter() { + override fun getCount(): Int = previews.size + override fun getItem(position: Int): SnapshotSlotPreview = previews[position] + override fun getItemId(position: Int): Long = previews[position].slot.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false) + val preview = getItem(position) + + val slotLabel = view.findViewById(R.id.snapshot_slot_label) + val gameTitle = view.findViewById(R.id.snapshot_game_title) + val previewHint = view.findViewById(R.id.snapshot_preview_hint) + val thumbnail = view.findViewById(R.id.snapshot_thumbnail) + + slotLabel.text = preview.slotLabel + gameTitle.text = preview.gameTitle + + if (preview.thumbnail != null) { + thumbnail.setImageBitmap(preview.thumbnail) + previewHint.text = getString(R.string.snapshot_preview_tap_hint) + previewHint.visibility = View.VISIBLE + thumbnail.setOnClickListener { + showSnapshotPreviewDialog(preview) + } + } else { + thumbnail.setImageResource(android.R.drawable.ic_menu_report_image) + previewHint.text = getString(R.string.snapshot_preview_unavailable) + previewHint.visibility = View.VISIBLE + thumbnail.setOnClickListener(null) + } + + return view + } + } + + listView.adapter = adapter + listView.setOnItemClickListener { _, _, position, _ -> + val slot = previews[position].slot + dialog.dismiss() + launchMainActivityWithSnapshot(slot) + } + + dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(R.string.snapshot_select_load_slot) + .setView(listView) + .setNegativeButton(android.R.string.cancel, null) + .create() + + dialog.show() + } + private fun loadGames() { val folderUri = gamesFolderUri if (!isFolderReady(folderUri)) { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt index 2be871e187..00cc5853f8 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt @@ -2,20 +2,44 @@ package com.izzy2lost.x1box import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.hardware.input.InputManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.InputDevice import android.view.KeyEvent import android.view.View +import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowInsetsController +import android.widget.BaseAdapter import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.libsdl.app.SDLActivity +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder class MainActivity : SDLActivity(), InputManager.InputDeviceListener { + companion object { + const val EXTRA_AUTO_LOAD_SNAPSHOT_SLOT = "com.izzy2lost.x1box.extra.AUTO_LOAD_SNAPSHOT_SLOT" + private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12 + private const val TOTAL_SNAPSHOT_SLOTS = 10 + } + + private data class SnapshotSlotPreview( + val slot: Int, + val slotLabel: String, + val gameTitle: String, + val thumbnail: Bitmap?, + ) + private var onScreenController: OnScreenController? = null private var controllerBridge: ControllerInputBridge? = null private var isControllerVisible = false @@ -25,9 +49,15 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { private var startButtonDown = false private var selectButtonDown = false private var comboTriggered = false + private var startupSnapshotSlot: Int? = null + private var startupSnapshotLoadScheduled = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val requestedSlot = intent?.getIntExtra(EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, 0) ?: 0 + if (requestedSlot in 1..TOTAL_SNAPSHOT_SLOTS) { + startupSnapshotSlot = requestedSlot + } setupOnScreenController() setupControllerDetection() hideSystemUI() @@ -119,6 +149,35 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { mLayout?.postDelayed({ registerVirtualController() }, 1000) + + scheduleStartupSnapshotLoadIfRequested() + } + + private fun scheduleStartupSnapshotLoadIfRequested() { + val slot = startupSnapshotSlot ?: return + if (startupSnapshotLoadScheduled) { + return + } + startupSnapshotLoadScheduled = true + + val hostView = mLayout ?: window.decorView + hostView.postDelayed({ + Thread { + val ok = nativeLoadSnapshot(slotName(slot)) + runOnUiThread { + if (ok) { + writeSnapshotTitleFallback(slot) + } + val msg = if (ok) { + getString(R.string.snapshot_loaded, slot) + } else { + getString(R.string.snapshot_load_failed, slot) + } + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + } + }.start() + startupSnapshotSlot = null + }, 2500) } private fun registerVirtualController() { @@ -285,6 +344,273 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { ((source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) } + private external fun nativeSaveSnapshot(name: String): Boolean + private external fun nativeLoadSnapshot(name: String): Boolean + + private fun slotName(slot: Int) = "android_slot_$slot" + + private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots") + + private fun snapshotPreviewDirs(): List { + val dirs = ArrayList(2) + dirs.add(snapshotPreviewDir()) + getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) } + return dirs.distinctBy { it.absolutePath } + } + + private fun slotNameAliases(slot: Int): List { + val aliases = linkedSetOf( + slotName(slot), + "slot_$slot", + "slot$slot", + "snapshot_$slot", + ) + return aliases.toList() + } + + private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? { + for (dir in snapshotPreviewDirs()) { + for (name in slotNameAliases(slot)) { + val file = File(dir, "$name.$extension") + if (file.isFile) { + return file + } + } + } + return null + } + + private fun snapshotPreviewTitleFile(slot: Int): File = + File(snapshotPreviewDir(), "${slotName(slot)}.title") + + private fun extractDisplayName(rawName: String?): String? { + if (rawName.isNullOrBlank()) { + return null + } + val decoded = Uri.decode(rawName) + val leaf = decoded.substringAfterLast('/').substringAfterLast(':') + if (leaf.isBlank()) { + return null + } + val stem = leaf.substringBeforeLast('.', leaf).trim() + return stem.takeIf { it.isNotEmpty() } + } + + private fun fallbackCurrentGameName(): String { + val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE) + val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name }) + if (!pathName.isNullOrEmpty()) { + return pathName + } + val uriName = extractDisplayName(prefs.getString("dvdUri", null)) + if (!uriName.isNullOrEmpty()) { + return uriName + } + return getString(R.string.snapshot_unknown_game) + } + + private fun writeSnapshotTitleFallback(slot: Int) { + val file = snapshotPreviewTitleFile(slot) + if (runCatching { file.exists() && file.readText(Charsets.UTF_8).trim().isNotEmpty() }.getOrDefault(false)) { + return + } + + val title = fallbackCurrentGameName().trim() + if (title.isEmpty() || title == getString(R.string.snapshot_unknown_game)) { + return + } + + runCatching { + val dir = snapshotPreviewDir() + if (!dir.exists()) { + dir.mkdirs() + } + file.writeText(title, Charsets.UTF_8) + } + } + + private fun readSnapshotGameTitle(slot: Int): String { + val title = runCatching { + val file = resolveSnapshotPreviewFile(slot, "title") + if (file != null && file.exists()) { + file.readText(Charsets.UTF_8).trim() + } else { + "" + } + }.getOrDefault("") + + if (title.isNotEmpty()) { + return title + } + + return if (resolveSnapshotPreviewFile(slot, "thm") != null) { + fallbackCurrentGameName() + } else { + getString(R.string.snapshot_empty_slot) + } + } + + private fun decodeSnapshotThumbnail(slot: Int): Bitmap? { + val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null + val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null + if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) { + return null + } + + if (bytes[0] != 'X'.code.toByte() || + bytes[1] != '1'.code.toByte() || + bytes[2] != 'T'.code.toByte() || + bytes[3] != 'H'.code.toByte()) { + return null + } + + val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN) + val version = header.short.toInt() and 0xFFFF + val width = header.short.toInt() and 0xFFFF + val height = header.short.toInt() and 0xFFFF + val channels = header.short.toInt() and 0xFFFF + + if (version != 1 || channels != 4 || width <= 0 || height <= 0) { + return null + } + + val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong() + if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) { + return null + } + + val pixelBytes = pixelBytesLong.toInt() + if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) { + return null + } + + val pixels = IntArray(width * height) + var src = SNAPSHOT_PREVIEW_HEADER_SIZE + for (y in 0 until height) { + val dstRow = (height - 1 - y) * width + for (x in 0 until width) { + val r = bytes[src].toInt() and 0xFF + val g = bytes[src + 1].toInt() and 0xFF + val b = bytes[src + 2].toInt() and 0xFF + pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + src += 4 + } + } + + return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + private fun loadSnapshotSlotPreviews(): List { + return (1..TOTAL_SNAPSHOT_SLOTS).map { slot -> + SnapshotSlotPreview( + slot = slot, + slotLabel = getString(R.string.snapshot_slot_label, slot), + gameTitle = readSnapshotGameTitle(slot), + thumbnail = decodeSnapshotThumbnail(slot), + ) + } + } + + private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) { + val bitmap = preview.thumbnail ?: return + val image = ImageView(this).apply { + setImageBitmap(bitmap) + adjustViewBounds = true + scaleType = ImageView.ScaleType.FIT_CENTER + setPadding(16, 16, 16, 16) + } + + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle)) + .setView(image) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun runSnapshotOperation(slot: Int, save: Boolean) { + Thread { + val ok = if (save) nativeSaveSnapshot(slotName(slot)) else nativeLoadSnapshot(slotName(slot)) + runOnUiThread { + if (ok) { + writeSnapshotTitleFallback(slot) + } + val msg = if (save) { + if (ok) getString(R.string.snapshot_saved, slot) else getString(R.string.snapshot_save_failed) + } else { + if (ok) getString(R.string.snapshot_loaded, slot) else getString(R.string.snapshot_load_failed, slot) + } + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + } + }.start() + } + + private fun showSnapshotSlotDialog(save: Boolean) { + val previews = loadSnapshotSlotPreviews() + val listView = ListView(this) + lateinit var dialog: AlertDialog + + val adapter = object : BaseAdapter() { + override fun getCount(): Int = previews.size + override fun getItem(position: Int): SnapshotSlotPreview = previews[position] + override fun getItemId(position: Int): Long = previews[position].slot.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false) + val preview = getItem(position) + + val slotLabel = view.findViewById(R.id.snapshot_slot_label) + val gameTitle = view.findViewById(R.id.snapshot_game_title) + val previewHint = view.findViewById(R.id.snapshot_preview_hint) + val thumbnail = view.findViewById(R.id.snapshot_thumbnail) + + slotLabel.text = preview.slotLabel + gameTitle.text = preview.gameTitle + + if (preview.thumbnail != null) { + thumbnail.setImageBitmap(preview.thumbnail) + previewHint.text = getString(R.string.snapshot_preview_tap_hint) + previewHint.visibility = View.VISIBLE + thumbnail.setOnClickListener { + showSnapshotPreviewDialog(preview) + } + } else { + thumbnail.setImageResource(android.R.drawable.ic_menu_report_image) + previewHint.text = getString(R.string.snapshot_preview_unavailable) + previewHint.visibility = View.VISIBLE + thumbnail.setOnClickListener(null) + } + + return view + } + } + + listView.adapter = adapter + listView.setOnItemClickListener { _, _, position, _ -> + val slot = previews[position].slot + dialog.dismiss() + runSnapshotOperation(slot, save) + } + + dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog) + .setTitle( + if (save) getString(R.string.snapshot_select_save_slot) + else getString(R.string.snapshot_select_load_slot) + ) + .setView(listView) + .setNegativeButton(android.R.string.cancel, null) + .create() + + dialog.show() + } + + private fun showSaveStateDialog() { + showSnapshotSlotDialog(save = true) + } + + private fun showLoadStateDialog() { + showSnapshotSlotDialog(save = false) + } + private fun showInGameMenu() { val options = arrayOf( getString(R.string.in_game_menu_resume), @@ -293,6 +619,8 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } else { getString(R.string.in_game_menu_show_touch_controls) }, + getString(R.string.in_game_menu_save_state), + getString(R.string.in_game_menu_load_state), getString(R.string.in_game_menu_exit_to_library), getString(R.string.in_game_menu_quit_app), ) @@ -301,12 +629,12 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { .setTitle(getString(R.string.in_game_menu_title)) .setItems(options) { _, which -> when (which) { - 0 -> { - // Resume - } + 0 -> { /* Resume — dismiss dialog */ } 1 -> toggleOnScreenController() - 2 -> exitToGameLibrary() - 3 -> finishAffinity() + 2 -> showSaveStateDialog() + 3 -> showLoadStateDialog() + 4 -> exitToGameLibrary() + 5 -> finishAffinity() } } .setOnDismissListener { diff --git a/android/app/src/main/res/layout/activity_game_library.xml b/android/app/src/main/res/layout/activity_game_library.xml index 8f560f12ed..56fa1ef4c8 100644 --- a/android/app/src/main/res/layout/activity_game_library.xml +++ b/android/app/src/main/res/layout/activity_game_library.xml @@ -97,6 +97,17 @@ app:iconGravity="textStart" app:iconPadding="8dp" /> + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6a9493adf3..5fe742e793 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ Game Library Change Folder + Snapshots Convert ISO to XISO List Cover Grid @@ -108,4 +109,19 @@ Hide Touch Controls Exit to Game Library Quit App + Save State + Load State + Slot %1$d + State saved to Slot %1$d + Save failed + State loaded from Slot %1$d + No save found in Slot %1$d + Select slot to save + Select slot to load + Empty slot + Unknown game + Tap thumbnail to enlarge + No screenshot yet + Slot %1$d - %2$s + Snapshot thumbnail diff --git a/ui/xemu-snapshots.h b/ui/xemu-snapshots.h index 354322ee56..89aecc0b03 100644 --- a/ui/xemu-snapshots.h +++ b/ui/xemu-snapshots.h @@ -27,7 +27,7 @@ extern "C" { #include "qemu/osdep.h" #include "block/snapshot.h" #if defined(ANDROID) || defined(__ANDROID__) -typedef unsigned int GLuint; +#include #else #include #endif diff --git a/ui/xemu.c b/ui/xemu.c index a7a39cb51e..675b503062 100644 --- a/ui/xemu.c +++ b/ui/xemu.c @@ -1762,9 +1762,14 @@ void sdl2_gl_refresh(DisplayChangeListener *dcl) glClear(GL_COLOR_BUFFER_BIT); #ifdef __ANDROID__ android_blit_frame(tex, flip_required); -#else -#endif -#ifdef __ANDROID__ + /* + * Android uses a HUD stub, but snapshot JNI requests are dispatched + * from xemu_hud_render(). Keep this hook active each frame so save/load + * requests run on the SDL/QEMU render thread. + */ + xemu_snapshots_set_framebuffer_texture(tex, flip_required); + xemu_hud_set_framebuffer_texture(tex, flip_required); + xemu_hud_render(); android_log_gl_error("refresh-blit"); #else xemu_snapshots_set_framebuffer_texture(tex, flip_required);