From 6d4a3bb5a552585b42fe7252f12abf03380c40b0 Mon Sep 17 00:00:00 2001 From: Connor McLaughlin Date: Sun, 21 Mar 2021 00:40:03 +1000 Subject: [PATCH] Android: Add MemoryCardImage class --- .../app/src/cpp/android_host_interface.cpp | 268 +++++++++++++++++- android/app/src/cpp/android_host_interface.h | 6 +- .../duckstation/MemoryCardFileInfo.java | 64 +++++ .../stenzek/duckstation/MemoryCardImage.java | 203 +++++++++++++ 4 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardFileInfo.java create mode 100644 android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardImage.java diff --git a/android/app/src/cpp/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index 8bad12d5c..ef7c756d9 100644 --- a/android/app/src/cpp/android_host_interface.cpp +++ b/android/app/src/cpp/android_host_interface.cpp @@ -14,6 +14,7 @@ #include "core/controller.h" #include "core/gpu.h" #include "core/host_display.h" +#include "core/memory_card_image.h" #include "core/system.h" #include "frontend-common/cheevos.h" #include "frontend-common/game_list.h" @@ -60,6 +61,8 @@ static jclass s_SaveStateInfo_class; static jmethodID s_SaveStateInfo_constructor; static jclass s_Achievement_class; static jmethodID s_Achievement_constructor; +static jclass s_MemoryCardFileInfo_class; +static jmethodID s_MemoryCardFileInfo_constructor; namespace AndroidHelpers { JavaVM* GetJavaVM() @@ -137,6 +140,58 @@ std::unique_ptr ReadInputStreamToMemory(JNIEnv* env, j env->DeleteLocalRef(cls); return bs; } + +std::vector ByteArrayToVector(JNIEnv* env, jbyteArray obj) +{ + std::vector ret; + const jsize size = obj ? env->GetArrayLength(obj) : 0; + if (size > 0) + { + jbyte* data = env->GetByteArrayElements(obj, nullptr); + ret.resize(static_cast(size)); + std::memcpy(ret.data(), data, ret.size()); + env->ReleaseByteArrayElements(obj, data, 0); + } + + return ret; +} + +jbyteArray NewByteArray(JNIEnv* env, const void* data, size_t size) +{ + if (!data || size == 0) + return nullptr; + + jbyteArray obj = env->NewByteArray(static_cast(size)); + jbyte* obj_data = env->GetByteArrayElements(obj, nullptr); + std::memcpy(obj_data, data, static_cast(static_cast(size))); + env->ReleaseByteArrayElements(obj, obj_data, 0); + return obj; +} + +jbyteArray VectorToByteArray(JNIEnv* env, const std::vector& data) +{ + if (data.empty()) + return nullptr; + + return NewByteArray(env, data.data(), data.size()); +} + +jobjectArray CreateObjectArray(JNIEnv* env, jclass object_class, const jobject* objects, size_t num_objects, + bool release_refs/* = false*/) +{ + if (!objects || num_objects == 0) + return nullptr; + + jobjectArray arr = env->NewObjectArray(static_cast(num_objects), object_class, nullptr); + for (jsize i = 0; i < static_cast(num_objects); i++) + { + env->SetObjectArrayElement(arr, i, objects[i]); + if (release_refs && objects[i]) + env->DeleteLocalRef(objects[i]); + } + + return arr; +} } // namespace AndroidHelpers AndroidHostInterface::AndroidHostInterface(jobject java_object, jobject context_object, std::string user_directory) @@ -897,7 +952,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) // Create global reference so it doesn't get cleaned up. JNIEnv* env = AndroidHelpers::GetJNIEnv(); jclass string_class, host_interface_class, patch_code_class, game_list_entry_class, save_state_info_class, - achievement_class; + achievement_class, memory_card_file_info_class; if ((string_class = env->FindClass("java/lang/String")) == nullptr || (s_String_class = static_cast(env->NewGlobalRef(string_class))) == nullptr || (host_interface_class = env->FindClass("com/github/stenzek/duckstation/AndroidHostInterface")) == nullptr || @@ -909,7 +964,9 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) (save_state_info_class = env->FindClass("com/github/stenzek/duckstation/SaveStateInfo")) == nullptr || (s_SaveStateInfo_class = static_cast(env->NewGlobalRef(save_state_info_class))) == nullptr || (achievement_class = env->FindClass("com/github/stenzek/duckstation/Achievement")) == nullptr || - (s_Achievement_class = static_cast(env->NewGlobalRef(achievement_class))) == nullptr) + (s_Achievement_class = static_cast(env->NewGlobalRef(achievement_class))) == nullptr || + (memory_card_file_info_class = env->FindClass("com/github/stenzek/duckstation/MemoryCardFileInfo")) == nullptr || + (s_MemoryCardFileInfo_class = static_cast(env->NewGlobalRef(memory_card_file_info_class))) == nullptr) { Log_ErrorPrint("AndroidHostInterface class lookup failed"); return -1; @@ -920,6 +977,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) env->DeleteLocalRef(patch_code_class); env->DeleteLocalRef(game_list_entry_class); env->DeleteLocalRef(achievement_class); + env->DeleteLocalRef(memory_card_file_info_class); jclass emulation_activity_class; if ((s_AndroidHostInterface_constructor = @@ -963,9 +1021,11 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) s_SaveStateInfo_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZII[B)V")) == nullptr || - (s_Achievement_constructor = - env->GetMethodID(s_Achievement_class, "", - "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V")) == nullptr) + (s_Achievement_constructor = env->GetMethodID( + s_Achievement_class, "", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V")) == nullptr || + (s_MemoryCardFileInfo_constructor = env->GetMethodID(s_MemoryCardFileInfo_class, "", + "(Ljava/lang/String;Ljava/lang/String;III[[B)V")) == nullptr) { Log_ErrorPrint("AndroidHostInterface lookups failed"); return -1; @@ -1878,3 +1938,201 @@ DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_cheevosLogout, jobject obj) { return Cheevos::Logout(); } + +static_assert(sizeof(MemoryCardImage::DataArray) == MemoryCardImage::DATA_SIZE); + +static MemoryCardImage::DataArray* GetMemoryCardData(JNIEnv* env, jbyteArray obj) +{ + if (!obj || env->GetArrayLength(obj) != MemoryCardImage::DATA_SIZE) + return nullptr; + + return reinterpret_cast(env->GetByteArrayElements(obj, nullptr)); +} + +static void ReleaseMemoryCardData(JNIEnv* env, jbyteArray obj, MemoryCardImage::DataArray* data) +{ + env->ReleaseByteArrayElements(obj, reinterpret_cast(data), 0); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, MemoryCardImage_isValid, jclass clazz, jbyteArray obj) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return false; + + const bool res = MemoryCardImage::IsValid(*data); + ReleaseMemoryCardData(env, obj, data); + return res; +} + +DEFINE_JNI_ARGS_METHOD(void, MemoryCardImage_format, jclass clazz, jbyteArray obj) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return; + + MemoryCardImage::Format(data); + ReleaseMemoryCardData(env, obj, data); +} + +DEFINE_JNI_ARGS_METHOD(jint, MemoryCardImage_getFreeBlocks, jclass clazz, jbyteArray obj) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return 0; + + const u32 free_blocks = MemoryCardImage::GetFreeBlockCount(*data); + ReleaseMemoryCardData(env, obj, data); + return free_blocks; +} + +DEFINE_JNI_ARGS_METHOD(jobjectArray, MemoryCardImage_getFiles, jclass clazz, jbyteArray obj) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return nullptr; + + const std::vector files(MemoryCardImage::EnumerateFiles(*data)); + + std::vector file_objects; + file_objects.reserve(files.size()); + + jclass byteArrayClass = env->FindClass("[B"); + + for (const MemoryCardImage::FileInfo& file : files) + { + jobject filename = env->NewStringUTF(file.filename.c_str()); + jobject title = env->NewStringUTF(file.title.c_str()); + jobjectArray frames = nullptr; + if (!file.icon_frames.empty()) + { + frames = env->NewObjectArray(static_cast(file.icon_frames.size()), byteArrayClass, nullptr); + for (jsize i = 0; i < static_cast(file.icon_frames.size()); i++) + { + static constexpr jsize frame_size = MemoryCardImage::ICON_WIDTH * MemoryCardImage::ICON_HEIGHT * sizeof(u32); + jbyteArray frame_data = env->NewByteArray(frame_size); + jbyte* frame_data_ptr = env->GetByteArrayElements(frame_data, nullptr); + std::memcpy(frame_data_ptr, file.icon_frames[i].pixels, frame_size); + env->ReleaseByteArrayElements(frame_data, frame_data_ptr, 0); + env->SetObjectArrayElement(frames, i, frame_data); + env->DeleteLocalRef(frame_data); + } + } + + file_objects.push_back(env->NewObject(s_MemoryCardFileInfo_class, s_MemoryCardFileInfo_constructor, filename, title, + static_cast(file.size), static_cast(file.first_block), + static_cast(file.num_blocks), frames)); + + env->DeleteLocalRef(frames); + env->DeleteLocalRef(title); + env->DeleteLocalRef(filename); + } + + jobjectArray file_object_array = + AndroidHelpers::CreateObjectArray(env, s_MemoryCardFileInfo_class, file_objects.data(), file_objects.size(), true); + ReleaseMemoryCardData(env, obj, data); + return file_object_array; +} + +DEFINE_JNI_ARGS_METHOD(jboolean, MemoryCardImage_hasFile, jclass clazz, jbyteArray obj, jstring filename) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return false; + + const std::string filename_str(AndroidHelpers::JStringToString(env, filename)); + bool result = false; + if (!filename_str.empty()) + { + const std::vector files(MemoryCardImage::EnumerateFiles(*data)); + result = std::any_of(files.begin(), files.end(), + [&filename_str](const MemoryCardImage::FileInfo& fi) { return fi.filename == filename_str; }); + } + + ReleaseMemoryCardData(env, obj, data); + return result; +} + +DEFINE_JNI_ARGS_METHOD(jbyteArray, MemoryCardImage_readFile, jclass clazz, jbyteArray obj, jstring filename) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return nullptr; + + const std::string filename_str(AndroidHelpers::JStringToString(env, filename)); + jbyteArray ret = nullptr; + if (!filename_str.empty()) + { + const std::vector files(MemoryCardImage::EnumerateFiles(*data)); + auto iter = std::find_if(files.begin(), files.end(), [&filename_str](const MemoryCardImage::FileInfo& fi) { + return fi.filename == filename_str; + }); + if (iter != files.end()) + { + std::vector file_data; + if (MemoryCardImage::ReadFile(*data, *iter, &file_data)) + ret = AndroidHelpers::VectorToByteArray(env, file_data); + } + } + + ReleaseMemoryCardData(env, obj, data); + return ret; +} + +DEFINE_JNI_ARGS_METHOD(jboolean, MemoryCardImage_writeFile, jclass clazz, jbyteArray obj, jstring filename, + jbyteArray file_data) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return false; + + const std::string filename_str(AndroidHelpers::JStringToString(env, filename)); + const std::vector file_data_vec(AndroidHelpers::ByteArrayToVector(env, file_data)); + bool ret = false; + if (!filename_str.empty()) + ret = MemoryCardImage::WriteFile(data, filename_str, file_data_vec); + + ReleaseMemoryCardData(env, obj, data); + return ret; +} + +DEFINE_JNI_ARGS_METHOD(jboolean, MemoryCardImage_deleteFile, jclass clazz, jbyteArray obj, jstring filename) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return false; + + const std::string filename_str(AndroidHelpers::JStringToString(env, filename)); + bool ret = false; + + if (!filename_str.empty()) + { + const std::vector files(MemoryCardImage::EnumerateFiles(*data)); + auto iter = std::find_if(files.begin(), files.end(), [&filename_str](const MemoryCardImage::FileInfo& fi) { + return fi.filename == filename_str; + }); + + if (iter != files.end()) + ret = MemoryCardImage::DeleteFile(data, *iter); + } + + ReleaseMemoryCardData(env, obj, data); + return ret; +} + +DEFINE_JNI_ARGS_METHOD(jboolean, MemoryCardImage_importCard, jclass clazz, jbyteArray obj, jstring filename, + jbyteArray import_data) +{ + MemoryCardImage::DataArray* data = GetMemoryCardData(env, obj); + if (!data) + return false; + + const std::string filename_str(AndroidHelpers::JStringToString(env, filename)); + std::vector import_data_vec(AndroidHelpers::ByteArrayToVector(env, import_data)); + bool ret = false; + if (!filename_str.empty() && !import_data_vec.empty()) + ret = MemoryCardImage::ImportCard(data, filename_str.c_str(), std::move(import_data_vec)); + + ReleaseMemoryCardData(env, obj, data); + return ret; +} \ No newline at end of file diff --git a/android/app/src/cpp/android_host_interface.h b/android/app/src/cpp/android_host_interface.h index 4e9dcfc00..ae7d7265f 100644 --- a/android/app/src/cpp/android_host_interface.h +++ b/android/app/src/cpp/android_host_interface.h @@ -120,7 +120,11 @@ AndroidHostInterface* GetNativeClass(JNIEnv* env, jobject obj); std::string JStringToString(JNIEnv* env, jstring str); std::unique_ptr ReadInputStreamToMemory(JNIEnv* env, jobject obj, u32 chunk_size = 65536); jclass GetStringClass(); - +std::vector ByteArrayToVector(JNIEnv* env, jbyteArray obj); +jbyteArray NewByteArray(JNIEnv* env, const void* data, size_t size); +jbyteArray VectorToByteArray(JNIEnv* env, const std::vector& data); +jobjectArray CreateObjectArray(JNIEnv* env, jclass object_class, const jobject* objects, size_t num_objects, + bool release_refs = false); } // namespace AndroidHelpers template diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardFileInfo.java b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardFileInfo.java new file mode 100644 index 000000000..e520c2db5 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardFileInfo.java @@ -0,0 +1,64 @@ +package com.github.stenzek.duckstation; + +import android.graphics.Bitmap; + +import java.nio.ByteBuffer; + +public class MemoryCardFileInfo { + public static final int ICON_WIDTH = 16; + public static final int ICON_HEIGHT = 16; + + private final String filename; + private final String title; + private final int size; + private final int firstBlock; + private final int numBlocks; + private final byte[][] iconFrames; + + public MemoryCardFileInfo(String filename, String title, int size, int firstBlock, int numBlocks, byte[][] iconFrames) { + this.filename = filename; + this.title = title; + this.size = size; + this.firstBlock = firstBlock; + this.numBlocks = numBlocks; + this.iconFrames = iconFrames; + } + + public String getFilename() { + return filename; + } + + public String getTitle() { + return title; + } + + public int getSize() { + return size; + } + + public int getFirstBlock() { + return firstBlock; + } + + public int getNumBlocks() { + return numBlocks; + } + + public int getNumIconFrames() { + return (iconFrames != null) ? iconFrames.length : 0; + } + + public byte[] getIconFrame(int index) { + return iconFrames[index]; + } + + public Bitmap getIconFrameBitmap(int index) { + final byte[] data = getIconFrame(index); + if (data == null) + return null; + + final Bitmap bitmap = Bitmap.createBitmap(ICON_WIDTH, ICON_HEIGHT, Bitmap.Config.ARGB_8888); + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)); + return bitmap; + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardImage.java b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardImage.java new file mode 100644 index 000000000..019c9bb3a --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardImage.java @@ -0,0 +1,203 @@ +package com.github.stenzek.duckstation; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.DocumentsContract; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; + +public class MemoryCardImage { + public static final int DATA_SIZE = 128 * 1024; + public static final String FILE_EXTENSION = ".mcd"; + + private final Context context; + private final Uri uri; + private final byte[] data; + + private MemoryCardImage(Context context, Uri uri, byte[] data) { + this.context = context; + this.uri = uri; + this.data = data; + } + + public static String getTitleForUri(Uri uri) { + String name = uri.getLastPathSegment(); + if (name != null) { + final int lastSlash = name.lastIndexOf('/'); + if (lastSlash >= 0) + name = name.substring(lastSlash + 1); + + if (name.endsWith(FILE_EXTENSION)) + name = name.substring(0, name.length() - FILE_EXTENSION.length()); + } else { + name = uri.toString(); + } + + return name; + } + + public static MemoryCardImage open(Context context, Uri uri) { + byte[] data = FileUtil.readBytesFromUri(context, uri, DATA_SIZE); + if (data == null) + return null; + + if (!isValid(data)) + return null; + + return new MemoryCardImage(context, uri, data); + } + + public static MemoryCardImage create(Context context, Uri uri) { + byte[] data = new byte[DATA_SIZE]; + format(data); + + MemoryCardImage card = new MemoryCardImage(context, uri, data); + if (!card.save()) + return null; + + return card; + } + + public static Uri[] getCardUris(Context context) { + final String directory = "/sdcard/duckstation/memcards"; + final ArrayList results = new ArrayList<>(); + + if (directory.charAt(0) == '/') { + // native path + final File directoryFile = new File(directory); + final File[] files = directoryFile.listFiles(); + for (File file : files) { + if (!file.isFile()) + continue; + + if (!file.getName().endsWith(FILE_EXTENSION)) + continue; + + results.add(Uri.fromFile(file)); + } + } else { + try { + final Uri baseUri = null; + final String[] scanProjection = new String[]{ + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE}; + final ContentResolver resolver = context.getContentResolver(); + final String treeDocId = DocumentsContract.getTreeDocumentId(baseUri); + final Uri queryUri = DocumentsContract.buildChildDocumentsUriUsingTree(baseUri, treeDocId); + final Cursor cursor = resolver.query(queryUri, scanProjection, null, null, null); + + while (cursor.moveToNext()) { + try { + final String mimeType = cursor.getString(2); + final String documentId = cursor.getString(0); + final Uri uri = DocumentsContract.buildDocumentUriUsingTree(baseUri, documentId); + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) { + continue; + } + + final String uriString = uri.toString(); + if (!uriString.endsWith(FILE_EXTENSION)) + continue; + + results.add(uri); + } catch (Exception e) { + e.printStackTrace(); + } + } + cursor.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (results.isEmpty()) + return null; + + Collections.sort(results, (a, b) -> a.compareTo(b)); + + final Uri[] resultArray = new Uri[results.size()]; + results.toArray(resultArray); + return resultArray; + } + + private static native boolean isValid(byte[] data); + + private static native void format(byte[] data); + + private static native int getFreeBlocks(byte[] data); + + private static native MemoryCardFileInfo[] getFiles(byte[] data); + + private static native boolean hasFile(byte[] data, String filename); + + private static native byte[] readFile(byte[] data, String filename); + + private static native boolean writeFile(byte[] data, String filename, byte[] fileData); + + private static native boolean deleteFile(byte[] data, String filename); + + private static native boolean importCard(byte[] data, String filename, byte[] importData); + + public boolean save() { + return FileUtil.writeBytesToUri(context, uri, data); + } + + public boolean delete() { + return FileUtil.deleteFileAtUri(context, uri); + } + + public boolean format() { + format(data); + return save(); + } + + public Uri getUri() { + return uri; + } + + public String getTitle() { + return getTitleForUri(uri); + } + + public int getFreeBlocks() { + return getFreeBlocks(data); + } + + public MemoryCardFileInfo[] getFiles() { + return getFiles(data); + } + + public boolean hasFile(String filename) { + return hasFile(data, filename); + } + + public byte[] readFile(String filename) { + return readFile(data, filename); + } + + public boolean writeFile(String filename, byte[] fileData) { + if (!writeFile(data, filename, fileData)) + return false; + + return save(); + } + + public boolean deleteFile(String filename) { + if (!deleteFile(data, filename)) + return false; + + return save(); + } + + public boolean importCard(String filename, byte[] importData) { + if (!importCard(data, filename, importData)) + return false; + + return save(); + } +}