From f73b921349a437940310b33c98996d78c5da1c04 Mon Sep 17 00:00:00 2001 From: SSimco <37044560+SSimco@users.noreply.github.com> Date: Sat, 4 Jan 2025 05:20:10 +0200 Subject: [PATCH] Added title manager --- .../BackendAArch64/BackendAArch64.cpp | 10 + src/Common/unix/ContentUriStream.h | 6 +- src/android/app/src/main/AndroidManifest.xml | 4 + .../src/main/cpp/AndroidFilesystemCallbacks.h | 8 +- .../main/cpp/AndroidGameTitleLoadedCallback.h | 6 +- .../src/main/cpp/AndroidSwkbdCallbacks.cpp | 2 +- src/android/app/src/main/cpp/CMakeLists.txt | 4 +- .../src/main/cpp/CompressTitleCallbacks.cpp | 20 + .../app/src/main/cpp/CompressTitleCallbacks.h | 17 + src/android/app/src/main/cpp/JNIUtils.cpp | 13 +- src/android/app/src/main/cpp/JNIUtils.h | 55 +- .../app/src/main/cpp/NativeActiveSettings.cpp | 8 +- .../app/src/main/cpp/NativeEmulation.cpp | 6 +- .../app/src/main/cpp/NativeGameTitles.cpp | 239 ++++- .../app/src/main/cpp/NativeGraphicPacks.cpp | 19 +- src/android/app/src/main/cpp/NativeInput.cpp | 16 +- .../app/src/main/cpp/NativeLogging.cpp | 4 +- .../app/src/main/cpp/NativeSettings.cpp | 4 +- src/android/app/src/main/cpp/NativeSwkbd.cpp | 4 +- src/android/app/src/main/cpp/WuaConverter.cpp | 216 +++++ src/android/app/src/main/cpp/WuaConverter.h | 57 ++ .../main/java/info/cemu/cemu/MainActivity.kt | 28 +- .../cemu/cemu/gamelist/GameListViewModel.kt | 11 + .../cemu/cemu/gamelist/GamesListScreen.kt | 7 + .../graphicpacks/GraphicPacksDownloader.kt | 2 +- .../java/info/cemu/cemu/guicore/Formatters.kt | 13 + .../cemu/cemu/inputoverlay/InputDrawable.kt | 2 +- .../InputOverlayDefaultConfigParser.kt | 3 +- .../nativeinterface/NativeActiveSettings.kt | 3 + .../cemu/cemu/nativeinterface/NativeFiles.kt | 10 +- .../cemu/nativeinterface/NativeGameTitles.kt | 115 +++ .../info/cemu/cemu/titlemanager/TitleEntry.kt | 53 ++ .../cemu/cemu/titlemanager/TitleInstaller.kt | 15 + .../cemu/titlemanager/TitleListViewModel.kt | 585 ++++++++++++ .../cemu/titlemanager/TitleManagerActivity.kt | 18 + .../cemu/titlemanager/TitleManagerScreen.kt | 849 ++++++++++++++++++ .../cemu/{drawable => utils}/Drawables.kt | 6 +- .../src/main/java/info/cemu/cemu/utils/IO.kt | 13 + .../main/java/info/cemu/cemu/utils/String.kt | 9 + .../java/info/cemu/cemu/{zip => utils}/Zip.kt | 6 +- .../app/src/main/res/values/strings.xml | 64 ++ 41 files changed, 2437 insertions(+), 93 deletions(-) create mode 100644 src/android/app/src/main/cpp/CompressTitleCallbacks.cpp create mode 100644 src/android/app/src/main/cpp/CompressTitleCallbacks.h create mode 100644 src/android/app/src/main/cpp/WuaConverter.cpp create mode 100644 src/android/app/src/main/cpp/WuaConverter.h create mode 100644 src/android/app/src/main/java/info/cemu/cemu/guicore/Formatters.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleEntry.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleInstaller.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleListViewModel.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerActivity.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerScreen.kt rename src/android/app/src/main/java/info/cemu/cemu/{drawable => utils}/Drawables.kt (75%) create mode 100644 src/android/app/src/main/java/info/cemu/cemu/utils/IO.kt create mode 100644 src/android/app/src/main/java/info/cemu/cemu/utils/String.kt rename src/android/app/src/main/java/info/cemu/cemu/{zip => utils}/Zip.kt (89%) diff --git a/src/Cafe/HW/Espresso/Recompiler/BackendAArch64/BackendAArch64.cpp b/src/Cafe/HW/Espresso/Recompiler/BackendAArch64/BackendAArch64.cpp index 134c29df..4fdce5d6 100644 --- a/src/Cafe/HW/Espresso/Recompiler/BackendAArch64/BackendAArch64.cpp +++ b/src/Cafe/HW/Espresso/Recompiler/BackendAArch64/BackendAArch64.cpp @@ -80,6 +80,10 @@ struct AArch64GenContext_t : CodeGenerator, CodeContext void gqr_generateScaleCode(VReg& dataReg, bool isLoad, bool scalePS1, const IMLReg& registerGQR); + void prepareJump(IMLSegment* target) + { + } + bool conditional_r_s32([[maybe_unused]] IMLInstruction* imlInstruction) { cemu_assert_unimplemented(); @@ -601,6 +605,12 @@ void AArch64GenContext_t::compare_s32(IMLInstruction* imlInstruction) cset(regR, cond); } +struct ConditionalJumpInfo +{ + WReg regBool; + bool mustBeTrue; +}; + void AArch64GenContext_t::cjump(const std::unordered_map& labels, IMLInstruction* imlInstruction, IMLSegment* imlSegment) { const Label& label = labels.at(imlSegment->nextSegmentBranchTaken); diff --git a/src/Common/unix/ContentUriStream.h b/src/Common/unix/ContentUriStream.h index 7532ddd3..1ee648f1 100644 --- a/src/Common/unix/ContentUriStream.h +++ b/src/Common/unix/ContentUriStream.h @@ -5,13 +5,11 @@ #include "Common/unix/FilesystemAndroid.h" -using fd_streambuf = boost::iostreams::stream_buffer; - -class ContentUriStream : public fd_streambuf, public std::istream +class ContentUriStream : public boost::iostreams::stream_buffer, public std::istream { public: explicit ContentUriStream(const std::filesystem::path& path) - : fd_streambuf(FilesystemAndroid::openContentUri(path), boost::iostreams::close_handle), std::istream(this) {} + : boost::iostreams::stream_buffer(FilesystemAndroid::openContentUri(path), boost::iostreams::close_handle), std::istream(this) {} bool is_open() { diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index dc2d8fa3..c2618445 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,10 @@ android:name=".settings.SettingsActivity" android:exported="false" android:parentActivityName=".MainActivity" /> + NewStringUTF(uri.c_str()); + jstring uriString = JNIUtils::toJString(env, uri); result = env->CallStaticBooleanMethod(*m_fileUtilClass, methodId, uriString); env->DeleteLocalRef(uriString); }).join(); @@ -40,7 +40,7 @@ class AndroidFilesystemCallbacks : public FilesystemAndroid::FilesystemCallbacks int fd = -1; std::thread([&, this]() { JNIUtils::ScopedJNIENV env; - jstring uriString = env->NewStringUTF(uri.c_str()); + jstring uriString = JNIUtils::toJString(env, uri); fd = env->CallStaticIntMethod(*m_fileUtilClass, m_openContentUriMid, uriString); env->DeleteLocalRef(uriString); }).join(); @@ -52,7 +52,7 @@ class AndroidFilesystemCallbacks : public FilesystemAndroid::FilesystemCallbacks std::vector paths; std::thread([&, this]() { JNIUtils::ScopedJNIENV env; - jstring uriString = env->NewStringUTF(uri.c_str()); + jstring uriString = JNIUtils::toJString(env, uri); jobjectArray pathsObjArray = static_cast(env->CallStaticObjectMethod(*m_fileUtilClass, m_listFilesMid, uriString)); env->DeleteLocalRef(uriString); jsize arrayLength = env->GetArrayLength(pathsObjArray); @@ -60,7 +60,7 @@ class AndroidFilesystemCallbacks : public FilesystemAndroid::FilesystemCallbacks for (jsize i = 0; i < arrayLength; i++) { jstring pathStr = static_cast(env->GetObjectArrayElement(pathsObjArray, i)); - paths.push_back(JNIUtils::JStringToString(*env, pathStr)); + paths.push_back(JNIUtils::toString(env, pathStr)); env->DeleteLocalRef(pathStr); } env->DeleteLocalRef(pathsObjArray); diff --git a/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h index 31504dc6..71ac63ca 100644 --- a/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h +++ b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h @@ -20,7 +20,7 @@ class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback m_gameTitleLoadedCallbackObj(gameTitleLoadedCallbackObj) { JNIUtils::ScopedJNIENV env; - m_bitmapFormat = JNIUtils::getEnumValue(*env, "android/graphics/Bitmap$Config", "ARGB_8888"); + m_bitmapFormat = JNIUtils::getEnumValue(env, "android/graphics/Bitmap$Config", "ARGB_8888"); m_gameConstructorMID = env->GetMethodID(*m_gamejclass, "", "(JLjava/lang/String;Ljava/lang/String;SSISSSIZLandroid/graphics/Bitmap;)V"); m_createBitmapMID = env->GetStaticMethodID(*m_bitmapClass, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;"); } @@ -28,8 +28,8 @@ class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback void onTitleLoaded(const Game& game, const std::shared_ptr& icon) override { static JNIUtils::ScopedJNIENV env; - jstring name = env->NewStringUTF(game.name.c_str()); - jstring path = game.path.has_value() ? env->NewStringUTF(game.path->c_str()) : nullptr; + jstring name = JNIUtils::toJString(env, game.name); + jstring path = game.path.has_value() ? JNIUtils::toJString(env, game.path.value()) : nullptr; jobject bitmap = nullptr; sint32 lastPlayedYear = 0, lastPlayedMonth = 0, lastPlayedDay = 0; if (game.lastPlayed.has_value()) diff --git a/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp index 6ef93a3c..21a7c819 100644 --- a/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp +++ b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp @@ -13,7 +13,7 @@ void AndroidSwkbdCallbacks::showSoftwareKeyboard(const std::string& initialText, { std::thread([&, this]() { JNIUtils::ScopedJNIENV env; - jstring j_initialText = env->NewStringUTF(initialText.c_str()); + jstring j_initialText = JNIUtils::toJString(env, initialText); JNIUtils::ScopedJNIENV()->CallStaticVoidMethod(*m_emulationActivityClass, m_showSoftwareKeyboardMethodID, j_initialText, maxLength); env->DeleteLocalRef(j_initialText); }).join(); diff --git a/src/android/app/src/main/cpp/CMakeLists.txt b/src/android/app/src/main/cpp/CMakeLists.txt index 3dd53f86..310161db 100644 --- a/src/android/app/src/main/cpp/CMakeLists.txt +++ b/src/android/app/src/main/cpp/CMakeLists.txt @@ -2,7 +2,7 @@ add_library(CemuAndroid SHARED AndroidAudio.cpp AndroidEmulatedController.cpp AndroidSwkbdCallbacks.cpp - CMakeLists.txt + CompressTitleCallbacks.cpp GameTitleLoader.cpp Image.cpp JNIUtils.cpp @@ -15,6 +15,7 @@ add_library(CemuAndroid SHARED NativeLogging.cpp NativeSettings.cpp NativeSwkbd.cpp + WuaConverter.cpp ) target_link_libraries(CemuAndroid PRIVATE @@ -24,4 +25,5 @@ target_link_libraries(CemuAndroid PRIVATE CemuComponents CemuCafe CemuBin + ZArchive::zarchive ) diff --git a/src/android/app/src/main/cpp/CompressTitleCallbacks.cpp b/src/android/app/src/main/cpp/CompressTitleCallbacks.cpp new file mode 100644 index 00000000..60bce301 --- /dev/null +++ b/src/android/app/src/main/cpp/CompressTitleCallbacks.cpp @@ -0,0 +1,20 @@ +#include "CompressTitleCallbacks.h" + +CompressTitleCallbacks::CompressTitleCallbacks(jobject compressTitleCallbacks) + : m_compressTitleCallbacks{compressTitleCallbacks} +{ + JNIUtils::ScopedJNIENV env; + JNIUtils::Scopedjclass compressTitleCallbacksClass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleCompressCallbacks"); + m_onFinishedMID = env->GetMethodID(*compressTitleCallbacksClass, "onFinished", "()V"); + m_onErrorMID = env->GetMethodID(*compressTitleCallbacksClass, "onError", "()V"); +} + +void CompressTitleCallbacks::onFinished() +{ + JNIUtils::ScopedJNIENV()->CallVoidMethod(*m_compressTitleCallbacks, m_onFinishedMID); +} + +void CompressTitleCallbacks::onError() +{ + JNIUtils::ScopedJNIENV()->CallVoidMethod(*m_compressTitleCallbacks, m_onErrorMID); +} diff --git a/src/android/app/src/main/cpp/CompressTitleCallbacks.h b/src/android/app/src/main/cpp/CompressTitleCallbacks.h new file mode 100644 index 00000000..c16895fe --- /dev/null +++ b/src/android/app/src/main/cpp/CompressTitleCallbacks.h @@ -0,0 +1,17 @@ +#pragma once + +#include "JNIUtils.h" + +class CompressTitleCallbacks +{ + jmethodID m_onFinishedMID; + jmethodID m_onErrorMID; + JNIUtils::Scopedjobject m_compressTitleCallbacks; + + public: + explicit CompressTitleCallbacks(jobject compressTitleCallbacks); + + void onFinished(); + + void onError(); +}; diff --git a/src/android/app/src/main/cpp/JNIUtils.cpp b/src/android/app/src/main/cpp/JNIUtils.cpp index bf598930..c3019c01 100644 --- a/src/android/app/src/main/cpp/JNIUtils.cpp +++ b/src/android/app/src/main/cpp/JNIUtils.cpp @@ -5,16 +5,6 @@ namespace JNIUtils JavaVM* g_jvm = nullptr; }; -std::string JNIUtils::JStringToString(JNIEnv* env, jstring jstr) -{ - if (jstr == nullptr) - return {}; - const char* c_str = env->GetStringUTFChars(jstr, nullptr); - std::string str(c_str); - env->ReleaseStringUTFChars(jstr, c_str); - return str; -} - jobject JNIUtils::createJavaStringArrayList(JNIEnv* env, const std::vector& strings) { jclass clsArrayList = env->FindClass("java/util/ArrayList"); @@ -60,8 +50,9 @@ jobject JNIUtils::createJavaLongArrayList(JNIEnv* env, const std::vectorFindClass("java/lang/Long"); jmethodID valueOf = env->GetStaticMethodID(longClass, "valueOf", "(J)Ljava/lang/Long;"); std::vector valuesJava; + valuesJava.reserve(values.size()); for (auto&& value : values) - valuesJava.push_back(env->CallStaticObjectMethod(longClass, valueOf, static_cast(value))); + valuesJava.push_back(env->CallStaticObjectMethod(longClass, valueOf, value)); env->DeleteLocalRef(longClass); return JNIUtils::createArrayList(env, valuesJava); } diff --git a/src/android/app/src/main/cpp/JNIUtils.h b/src/android/app/src/main/cpp/JNIUtils.h index 63f4108d..d91fb0d8 100644 --- a/src/android/app/src/main/cpp/JNIUtils.h +++ b/src/android/app/src/main/cpp/JNIUtils.h @@ -7,7 +7,20 @@ namespace JNIUtils { extern JavaVM* g_jvm; - std::string JStringToString(JNIEnv* env, jstring jstr); + inline std::string toString(JNIEnv* env, jstring jstr) + { + if (jstr == nullptr) + return {}; + const char* c_str = env->GetStringUTFChars(jstr, nullptr); + std::string str(c_str); + env->ReleaseStringUTFChars(jstr, c_str); + return str; + } + + inline jstring toJString(JNIEnv* env, const std::string& str) + { + return env->NewStringUTF(str.c_str()); + } jobject createJavaStringArrayList(JNIEnv* env, const std::vector& stringList); @@ -21,16 +34,17 @@ namespace JNIUtils ScopedJNIENV() { jint result = g_jvm->GetEnv((void**)&m_env, JNI_VERSION_1_6); - if (result == JNI_EDETACHED) - { - JavaVMAttachArgs args; - args.version = JNI_VERSION_1_6; - args.name = nullptr; - args.group = nullptr; - result = g_jvm->AttachCurrentThread(&m_env, &args); - if (result == JNI_OK) - m_threadWasAttached = true; - } + + if (result != JNI_EDETACHED) + return; + + JavaVMAttachArgs args; + args.version = JNI_VERSION_1_6; + args.name = nullptr; + args.group = nullptr; + result = g_jvm->AttachCurrentThread(&m_env, &args); + if (result == JNI_OK) + m_threadWasAttached = true; } JNIEnv*& operator*() @@ -43,6 +57,11 @@ namespace JNIUtils return m_env; } + operator JNIEnv*() const + { + return m_env; + } + ~ScopedJNIENV() { if (m_threadWasAttached) @@ -133,7 +152,7 @@ namespace JNIUtils explicit Scopedjclass(const std::string& className) { ScopedJNIENV scopedEnv; - jobject tempObj = scopedEnv->FindClass(className.c_str()); + jclass tempObj = scopedEnv->FindClass(className.c_str()); m_jclass = static_cast(scopedEnv->NewGlobalRef(tempObj)); scopedEnv->DeleteLocalRef(tempObj); } @@ -146,7 +165,7 @@ namespace JNIUtils bool isValid() const { - return m_jclass; + return m_jclass != nullptr; } jclass& operator*() @@ -161,4 +180,14 @@ namespace JNIUtils Scopedjobject getEnumValue(JNIEnv* env, const std::string& enumClassName, const std::string& enumName); jobject createArrayList(JNIEnv* env, const std::vector& objects); jobject createJavaLongArrayList(JNIEnv* env, const std::vector& values); + + template + jobject newObject(JNIEnv* env, const std::string& className, const std::string& ctrSig = "()V", TArgs... args) + { + jclass javaClass = env->FindClass(className.c_str()); + jmethodID ctrId = env->GetMethodID(javaClass, "", ctrSig.c_str()); + jobject obj = env->NewObject(javaClass, ctrId, std::forward(args)...); + env->DeleteLocalRef(javaClass); + return obj; + } } // namespace JNIUtils diff --git a/src/android/app/src/main/cpp/NativeActiveSettings.cpp b/src/android/app/src/main/cpp/NativeActiveSettings.cpp index 518598f4..4c7c8479 100644 --- a/src/android/app/src/main/cpp/NativeActiveSettings.cpp +++ b/src/android/app/src/main/cpp/NativeActiveSettings.cpp @@ -4,5 +4,11 @@ extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_getMLCPath(JNIEnv* env, [[maybe_unused]] jclass clazz) { - return env->NewStringUTF(ActiveSettings::GetMlcPath().c_str()); + return JNIUtils::toJString(env, ActiveSettings::GetMlcPath()); +} + +extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_getUserDataPath(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return JNIUtils::toJString(env, ActiveSettings::GetUserDataPath()); } diff --git a/src/android/app/src/main/cpp/NativeEmulation.cpp b/src/android/app/src/main/cpp/NativeEmulation.cpp index e55ce1a3..88bad82a 100644 --- a/src/android/app/src/main/cpp/NativeEmulation.cpp +++ b/src/android/app/src/main/cpp/NativeEmulation.cpp @@ -161,8 +161,8 @@ Java_info_cemu_cemu_nativeinterface_NativeEmulation_setReplaceTVWithPadView([[ma extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeEmulation_initializeActiveSettings(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring data_path, jstring cache_path) { - std::string dataPath = JNIUtils::JStringToString(env, data_path); - std::string cachePath = JNIUtils::JStringToString(env, cache_path); + std::string dataPath = JNIUtils::toString(env, data_path); + std::string cachePath = JNIUtils::toString(env, cache_path); std::set failedWriteAccess; ActiveSettings::SetPaths(false, {}, dataPath, dataPath, cachePath, dataPath, failedWriteAccess); } @@ -258,5 +258,5 @@ Java_info_cemu_cemu_nativeinterface_NativeEmulation_startGame([[maybe_unused]] J { GuiSystem::getWindowInfo().set_keystates_up(); NativeEmulation::initializeAudioDevices(); - return NativeEmulation::startGame(JNIUtils::JStringToString(env, launchPath)); + return NativeEmulation::startGame(JNIUtils::toString(env, launchPath)); } diff --git a/src/android/app/src/main/cpp/NativeGameTitles.cpp b/src/android/app/src/main/cpp/NativeGameTitles.cpp index f41e2444..6cd6fbed 100644 --- a/src/android/app/src/main/cpp/NativeGameTitles.cpp +++ b/src/android/app/src/main/cpp/NativeGameTitles.cpp @@ -1,8 +1,10 @@ -#include -#include "JNIUtils.h" -#include "Cafe/GameProfile/GameProfile.h" -#include "GameTitleLoader.h" #include "AndroidGameTitleLoadedCallback.h" +#include "Cafe/TitleList/SaveList.h" +#include "Cafe/GameProfile/GameProfile.h" +#include "JNIUtils.h" +#include "GameTitleLoader.h" +#include "WuaConverter.h" +#include "CompressTitleCallbacks.h" namespace NativeGameTitles { @@ -35,6 +37,8 @@ namespace NativeGameTitles s_currentGameProfile.Reset(); s_currentGameProfile.Load(titleId); } + + std::unique_ptr s_wuaConverter; } // namespace NativeGameTitles extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL @@ -169,8 +173,8 @@ class SaveListCallback name.replace(nl, 1, " - "); JNIUtils::ScopedJNIENV env; - jstring nameJava = env->NewStringUTF(name.c_str()); - jobject pathJava = env->NewStringUTF(saveInfo.GetPath().c_str()); + jstring nameJava = JNIUtils::toJString(env, name); + jstring pathJava = JNIUtils::toJString(env, saveInfo.GetPath()); jobject saveData = env->NewObject( *m_saveDataClass, m_saveDataConstructorMID, @@ -224,16 +228,15 @@ class TitleListCallbacks if (titleInfo.IsSystemDataTitle()) return; // don't show system data titles for now - JNIUtils::ScopedJNIENV env; - ParsedMetaXml* metaInfo = titleInfo.GetMetaInfo(); std::string name = metaInfo->GetLongName(GetConfig().console_language.GetValue()); const auto nl = name.find(L'\n'); if (nl != std::string::npos) name.replace(nl, 1, " - "); - jobject nameJava = env->NewStringUTF(name.c_str()); - jobject pathJava = env->NewStringUTF(titleInfo.GetPath().c_str()); + JNIUtils::ScopedJNIENV env; + jobject nameJava = JNIUtils::toJString(env, name); + jobject pathJava = JNIUtils::toJString(env, titleInfo.GetPath()); jobject titleData = env->NewObject( *m_titleDataClass, m_titleDataConstructorMID, @@ -324,4 +327,218 @@ Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setSaveListCallback([[maybe s_saveListCallback = nullptr; else s_saveListCallback = std::make_unique(save_list_callback); -} \ No newline at end of file +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_checkIfTitleExists(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring meta_path) +{ + TitleInfo titleInfo(fs::path(JNIUtils::toString(env, meta_path))); + + if (!titleInfo.IsValid()) + return nullptr; + + fs::path target_location = ActiveSettings::GetMlcPath(titleInfo.GetInstallPath()); + + auto createTitleExistsStatus = [&](jobject existsError = nullptr) { + if (existsError == nullptr) + existsError = JNIUtils::newObject(env, "info/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsError$None"); + return JNIUtils::newObject( + env, + "info/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsStatus", + "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsError;Ljava/lang/String;)V", + existsError, + JNIUtils::toJString(env, target_location)); + }; + + std::error_code ec; + if (!fs::exists(target_location, ec)) + { + return createTitleExistsStatus(); + } + + try + { + const TitleInfo tmp(target_location); + if (!tmp.IsValid()) + { + // does not exist / is not valid. We allow to overwrite it + return createTitleExistsStatus(); + } + + TitleIdParser tip(titleInfo.GetAppTitleId()); + TitleIdParser tipOther(tmp.GetAppTitleId()); + + jint oldType = static_cast(tip.GetType()); + jint toInstallType = static_cast(tipOther.GetType()); + if (oldType != toInstallType) + { + jobject err = JNIUtils::newObject( + env, + "info/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsError$DifferentType", + "(II)V", + oldType, + toInstallType); + return createTitleExistsStatus(err); + } + else if (tmp.GetAppTitleVersion() == titleInfo.GetAppTitleVersion()) + { + jobject err = JNIUtils::newObject(env, "info/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsError$SameVersion"); + return createTitleExistsStatus(err); + } + else if (tmp.GetAppTitleVersion() > titleInfo.GetAppTitleVersion()) + { + jobject err = JNIUtils::newObject(env, "info/cemu/cemu/nativeinterface/NativeGameTitles$TitleExistsError$NewVersion"); + return createTitleExistsStatus(err); + } + } catch (const std::exception& ex) + { + cemuLog_log(LogType::Force, "exist-error: {} at {}", ex.what(), _pathToUtf8(target_location)); + } + + return createTitleExistsStatus(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_addTitleFromPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring path) +{ + CafeTitleList::AddTitleFromPath(fs::path(JNIUtils::toString(env, path))); +} + +struct Title +{ + uint64 uid; + uint16 version; +}; + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_queueTitleToCompress(JNIEnv* env, [[maybe_unused]] jclass clazz, jlong titleId, jlong selectedUID, jobject titlesCallback) +{ + jclass titlesCallbackClass = env->GetObjectClass(titlesCallback); + jmethodID getTitlesMID = env->GetMethodID(titlesCallbackClass, "getTitlesByTitleId", "(J)[Linfo/cemu/cemu/nativeinterface/NativeGameTitles$TitleIdToTitlesCallback$Title;"); + jclass titleClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleIdToTitlesCallback$Title"); + jfieldID versionFieldId = env->GetFieldID(titleClass, "version", "S"); + jfieldID titleUIDFieldId = env->GetFieldID(titleClass, "titleUID", "J"); + + auto getTitlePrintPath = [&](const TitleInfo& titleInfo) -> jstring { + if (!titleInfo.IsValid()) + return nullptr; + return JNIUtils::toJString(env, titleInfo.GetPrintPath()); + }; + + auto getTitlesByTitleId = [&](uint64 titleId) -> std::vector { + auto titlesJava = static_cast<jobjectArray>(env->CallObjectMethod(titlesCallback, getTitlesMID, titleId)); + jsize arraySize = env->GetArrayLength(titlesJava); + std::vector<Title> titles; + titles.reserve(arraySize); + for (jsize i = 0; i < arraySize; i++) + { + jobject title = env->GetObjectArrayElement(titlesJava, i); + uint64 uid = env->GetLongField(title, titleUIDFieldId); + uint16 version = env->GetShortField(title, versionFieldId); + titles.push_back(Title{.uid = uid, .version = version}); + } + return titles; + }; + + TitleInfo titleInfo_base; + TitleInfo titleInfo_update; + TitleInfo titleInfo_aoc; + + titleId = TitleIdParser::MakeBaseTitleId(titleId); // if the titleId of a separate update is selected, this converts it back to the base titleId + TitleIdParser titleIdParser(titleId); + bool hasBaseTitleId = titleIdParser.GetType() != TitleIdParser::TITLE_TYPE::AOC; + bool hasUpdateTitleId = titleIdParser.CanHaveSeparateUpdateTitleId(); + TitleId updateTitleId = hasUpdateTitleId ? titleIdParser.GetSeparateUpdateTitleId() : 0; + + // todo - AOC titleIds might differ from the base/update game in other bits than the type. We have to use the meta data from the base/update to match aoc to the base title id + // for now we just assume they match + TitleId aocTitleId; + if (hasBaseTitleId) + aocTitleId = (titleId & (uint64)~0xFF00000000) | (uint64)0xC00000000; + else + aocTitleId = titleId; + + // find base and update + if (hasBaseTitleId) + { + for (const auto& title : getTitlesByTitleId(titleId)) + { + if (!titleInfo_base.IsValid()) + { + titleInfo_base = CafeTitleList::GetTitleInfoByUID(title.uid); + if (title.uid == selectedUID) + break; // prefer the users selection + } + } + } + if (hasUpdateTitleId) + { + for (const auto& title : getTitlesByTitleId(updateTitleId)) + { + if (!titleInfo_update.IsValid()) + { + titleInfo_update = CafeTitleList::GetTitleInfoByUID(title.uid); + if (title.uid == selectedUID) + break; + } + else + { + // if multiple updates are present use the newest one + if (titleInfo_update.GetAppTitleVersion() < title.version) + titleInfo_update = CafeTitleList::GetTitleInfoByUID(title.uid); + if (title.uid == selectedUID) + break; + } + } + } + // find AOC + for (const auto& title : getTitlesByTitleId(aocTitleId)) + { + titleInfo_aoc = CafeTitleList::GetTitleInfoByUID(title.uid); + if (title.uid == selectedUID) + break; + } + + NativeGameTitles::s_wuaConverter = std::make_unique<WuaConverter>(titleInfo_base, titleInfo_update, titleInfo_aoc); + + jobject compressTitleInfo = JNIUtils::newObject( + env, + "info/cemu/cemu/nativeinterface/NativeGameTitles$CompressTitleInfo", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + getTitlePrintPath(titleInfo_base), + getTitlePrintPath(titleInfo_update), + getTitlePrintPath(titleInfo_aoc)); + return compressTitleInfo; +} + +extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getCompressedFileNameForQueuedTitle(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + if (NativeGameTitles::s_wuaConverter == nullptr) + return nullptr; + return JNIUtils::toJString(env, NativeGameTitles::s_wuaConverter->getCompressedFileName()); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_compressQueuedTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint fd, jobject compressTitleCallbacks) +{ + if (NativeGameTitles::s_wuaConverter == nullptr) + return; + NativeGameTitles::s_wuaConverter->startConversion(fd, std::make_unique<CompressTitleCallbacks>(compressTitleCallbacks)); +} + +extern "C" [[maybe_unused]] JNIEXPORT jlong JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getCurrentProgressForCompression([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + if (NativeGameTitles::s_wuaConverter == nullptr) + return 0L; + return NativeGameTitles::s_wuaConverter->getTransferredInputBytes(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_cancelTitleCompression([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + if (NativeGameTitles::s_wuaConverter == nullptr) + return; + NativeGameTitles::s_wuaConverter.reset(); +} diff --git a/src/android/app/src/main/cpp/NativeGraphicPacks.cpp b/src/android/app/src/main/cpp/NativeGraphicPacks.cpp index f95e8b5e..8582f454 100644 --- a/src/android/app/src/main/cpp/NativeGraphicPacks.cpp +++ b/src/android/app/src/main/cpp/NativeGraphicPacks.cpp @@ -59,7 +59,7 @@ namespace NativeGraphicPacks continue; } - jstring categoryJStr = category.empty() ? nullptr : env->NewStringUTF(category.c_str()); + jstring categoryJStr = category.empty() ? nullptr : JNIUtils::toJString(env, category); std::vector<std::string> presetSelections; std::optional<std::string> activePreset; for (auto& pentry : entry) @@ -74,10 +74,10 @@ namespace NativeGraphicPacks } jstring activePresetJstr = nullptr; - if (activePreset) - activePresetJstr = env->NewStringUTF(activePreset->c_str()); + if (activePreset.has_value()) + activePresetJstr = JNIUtils::toJString(env, activePreset.value()); else if (!presetSelections.empty()) - activePresetJstr = env->NewStringUTF(presetSelections.front().c_str()); + activePresetJstr = JNIUtils::toJString(env, presetSelections.front()); auto presetJObject = env->NewObject(graphicPackPresetClass, graphicPackPresetCtorId, id, categoryJStr, JNIUtils::createJavaStringArrayList(env, presetSelections), activePresetJstr); presetsJobjects.push_back(presetJObject); } @@ -103,9 +103,10 @@ Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_getGraphicPackBasicInfos( auto graphicPackInfoCtorId = env->GetMethodID(graphicPackInfoClass, "<init>", "(JLjava/lang/String;ZLjava/util/ArrayList;)V"); std::vector<jobject> graphicPackInfoJObjects; + graphicPackInfoJObjects.reserve(NativeGraphicPacks::s_graphicPacks.size()); for (auto&& graphicPack : NativeGraphicPacks::s_graphicPacks) { - jstring virtualPath = env->NewStringUTF(graphicPack.second->GetVirtualPath().c_str()); + jstring virtualPath = JNIUtils::toJString(env, graphicPack.second->GetVirtualPath()); jlong id = graphicPack.first; jobject titleIds = JNIUtils::createJavaLongArrayList(env, graphicPack.second->GetTitleIds()); jobject jGraphicPack = env->NewObject(graphicPackInfoClass, graphicPackInfoCtorId, id, virtualPath, graphicPack.second->IsEnabled(), titleIds); @@ -121,8 +122,8 @@ Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_getGraphicPack(JNIEnv* en auto graphicPackCtorId = env->GetMethodID(graphicPackClass, "<init>", "(JZLjava/lang/String;Ljava/lang/String;Ljava/util/ArrayList;)V"); auto graphicPack = NativeGraphicPacks::s_graphicPacks.at(id); - jstring graphicPackName = env->NewStringUTF(graphicPack->GetName().c_str()); - jstring graphicPackDescription = env->NewStringUTF(graphicPack->GetDescription().c_str()); + jstring graphicPackName = JNIUtils::toJString(env, graphicPack->GetName()); + jstring graphicPackDescription = JNIUtils::toJString(env, graphicPack->GetDescription()); return env->NewObject( graphicPackClass, graphicPackCtorId, @@ -144,9 +145,9 @@ Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_setGraphicPackActive([[ma extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_setGraphicPackActivePreset([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong id, jstring category, jstring preset) { - std::string presetCategory = category == nullptr ? "" : JNIUtils::JStringToString(env, category); + std::string presetCategory = category == nullptr ? "" : JNIUtils::toString(env, category); auto graphicPack = NativeGraphicPacks::s_graphicPacks.at(id); - graphicPack->SetActivePreset(presetCategory, JNIUtils::JStringToString(env, preset)); + graphicPack->SetActivePreset(presetCategory, JNIUtils::toString(env, preset)); NativeGraphicPacks::saveGraphicPackStateToConfig(graphicPack); } diff --git a/src/android/app/src/main/cpp/NativeInput.cpp b/src/android/app/src/main/cpp/NativeInput.cpp index 68f4ac08..a2785e4f 100644 --- a/src/android/app/src/main/cpp/NativeInput.cpp +++ b/src/android/app/src/main/cpp/NativeInput.cpp @@ -24,8 +24,8 @@ namespace NativeInput extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeInput_onNativeKey(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring device_descriptor, jstring device_name, jint key, jboolean is_pressed) { - auto deviceDescriptor = JNIUtils::JStringToString(env, device_descriptor); - auto deviceName = JNIUtils::JStringToString(env, device_name); + auto deviceDescriptor = JNIUtils::toString(env, device_descriptor); + auto deviceName = JNIUtils::toString(env, device_name); auto apiProvider = InputManager::instance().get_api_provider(InputAPI::Android); auto androidControllerProvider = dynamic_cast<AndroidControllerProvider*>(apiProvider.get()); androidControllerProvider->on_key_event(deviceDescriptor, deviceName, key, is_pressed); @@ -34,8 +34,8 @@ Java_info_cemu_cemu_nativeinterface_NativeInput_onNativeKey(JNIEnv* env, [[maybe extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeInput_onNativeAxis(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring device_descriptor, jstring device_name, jint axis, jfloat value) { - auto deviceDescriptor = JNIUtils::JStringToString(env, device_descriptor); - auto deviceName = JNIUtils::JStringToString(env, device_name); + auto deviceDescriptor = JNIUtils::toString(env, device_descriptor); + auto deviceName = JNIUtils::toString(env, device_name); auto apiProvider = InputManager::instance().get_api_provider(InputAPI::Android); auto androidControllerProvider = dynamic_cast<AndroidControllerProvider*>(apiProvider.get()); androidControllerProvider->on_axis_event(deviceDescriptor, deviceName, axis, value); @@ -118,8 +118,8 @@ Java_info_cemu_cemu_nativeinterface_NativeInput_isControllerDisabled([[maybe_unu extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeInput_setControllerMapping(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring device_descriptor, jstring device_name, jint index, jint mapping_id, jint button_id) { - auto deviceName = JNIUtils::JStringToString(env, device_name); - auto deviceDescriptor = JNIUtils::JStringToString(env, device_descriptor); + auto deviceName = JNIUtils::toString(env, device_name); + auto deviceDescriptor = JNIUtils::toString(env, device_descriptor); auto apiProvider = InputManager::instance().get_api_provider(InputAPI::Android); auto controller = ControllerFactory::CreateController(InputAPI::Android, deviceDescriptor, deviceName); AndroidEmulatedController::getAndroidEmulatedController(index).setMapping(mapping_id, controller, button_id); @@ -129,7 +129,7 @@ extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL Java_info_cemu_cemu_nativeinterface_NativeInput_getControllerMapping(JNIEnv* env, [[maybe_unused]] jclass clazz, jint index, jint mapping_id) { auto mapping = AndroidEmulatedController::getAndroidEmulatedController(index).getMapping(mapping_id); - return env->NewStringUTF(mapping.value_or("").c_str()); + return JNIUtils::toJString(env, mapping.value_or("")); } extern "C" [[maybe_unused]] JNIEXPORT void JNICALL @@ -151,7 +151,7 @@ Java_info_cemu_cemu_nativeinterface_NativeInput_getControllerMappings(JNIEnv* en for (const auto& pair : mappings) { jint key = pair.first; - jstring buttonName = env->NewStringUTF(pair.second.c_str()); + jstring buttonName = JNIUtils::toJString(env, pair.second); jobject mappingId = env->NewObject(integerClass, integerConstructor, key); env->CallObjectMethod(hashMapObj, hashMapPut, mappingId, buttonName); } diff --git a/src/android/app/src/main/cpp/NativeLogging.cpp b/src/android/app/src/main/cpp/NativeLogging.cpp index c00b9b04..02d0dc87 100644 --- a/src/android/app/src/main/cpp/NativeLogging.cpp +++ b/src/android/app/src/main/cpp/NativeLogging.cpp @@ -4,7 +4,7 @@ extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeLogging_log(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring message) { - cemuLog_log(LogType::Force, JNIUtils::JStringToString(env, message)); + cemuLog_log(LogType::Force, JNIUtils::toString(env, message)); } extern "C" [[maybe_unused]] JNIEXPORT void JNICALL @@ -13,5 +13,5 @@ Java_info_cemu_cemu_nativeinterface_NativeLogging_crashLog(JNIEnv* env, [[maybe_ if (!CrashLog_Create()) return; // give up if crashlog was already created CrashLog_WriteLine("Unhandled exception from java code"); - CrashLog_WriteLine(JNIUtils::JStringToString(env, stacktrace)); + CrashLog_WriteLine(JNIUtils::toString(env, stacktrace)); } diff --git a/src/android/app/src/main/cpp/NativeSettings.cpp b/src/android/app/src/main/cpp/NativeSettings.cpp index e4f1c4de..c6c4f867 100644 --- a/src/android/app/src/main/cpp/NativeSettings.cpp +++ b/src/android/app/src/main/cpp/NativeSettings.cpp @@ -188,7 +188,7 @@ extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeSettings_addGamesPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) { auto& gamePaths = g_config.data().game_paths; - auto gamePath = JNIUtils::JStringToString(env, uri); + auto gamePath = JNIUtils::toString(env, uri); if (std::any_of(gamePaths.begin(), gamePaths.end(), [&](auto path) { return path == gamePath; })) return; gamePaths.push_back(gamePath); @@ -198,7 +198,7 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_addGamesPath(JNIEnv* env, [[m extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeSettings_removeGamesPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) { - auto gamePath = JNIUtils::JStringToString(env, uri); + auto gamePath = JNIUtils::toString(env, uri); auto& gamePaths = g_config.data().game_paths; std::erase_if(gamePaths, [&](auto path) { return path == gamePath; }); g_config.Save(); diff --git a/src/android/app/src/main/cpp/NativeSwkbd.cpp b/src/android/app/src/main/cpp/NativeSwkbd.cpp index 713b807e..c711114a 100644 --- a/src/android/app/src/main/cpp/NativeSwkbd.cpp +++ b/src/android/app/src/main/cpp/NativeSwkbd.cpp @@ -34,13 +34,13 @@ Java_info_cemu_cemu_nativeinterface_NativeSwkbd_initializeSwkbd([[maybe_unused]] extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeSwkbd_setCurrentInputText([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jstring initial_text) { - NativeSwkbd::s_currentInputText = JNIUtils::JStringToString(env, initial_text); + NativeSwkbd::s_currentInputText = JNIUtils::toString(env, initial_text); } extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_cemu_nativeinterface_NativeSwkbd_onTextChanged([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_text) { - std::string text = JNIUtils::JStringToString(env, j_text); + std::string text = JNIUtils::toString(env, j_text); auto stringDiff = NativeSwkbd::getStringDiffs(text, NativeSwkbd::s_currentInputText); for (size_t i = 0; i < stringDiff.numberOfCharacterToDelete; i++) swkbd::keyInput(swkbd::BACKSPACE_KEYCODE); diff --git a/src/android/app/src/main/cpp/WuaConverter.cpp b/src/android/app/src/main/cpp/WuaConverter.cpp new file mode 100644 index 00000000..f6bcd04e --- /dev/null +++ b/src/android/app/src/main/cpp/WuaConverter.cpp @@ -0,0 +1,216 @@ +#include "WuaConverter.h" + +WuaConverter::WuaConverter(const TitleInfo& titleInfo_base, const TitleInfo& titleInfo_update, const TitleInfo& titleInfo_aoc) + : m_titleInfo_base{titleInfo_base}, + m_titleInfo_update{titleInfo_update}, + m_titleInfo_aoc{titleInfo_aoc} +{ +} +WuaConverter::~WuaConverter() +{ + m_writerContext.cancelled = true; + if (m_workerThread.joinable()) + m_workerThread.join(); +} + +void WuaConverter::startConversion(int fd, std::unique_ptr<CompressTitleCallbacks>&& callbacks) +{ + m_workerThread = std::thread([callbacks(std::move(callbacks)), fd, this]() { + if (fd == -1) + { + callbacks->onError(); + return; + } + + struct ScopedFd + { + int fd; + ~ScopedFd() + { + close(fd); + } + } scopedFd{.fd = fd}; + + if (m_started) + return; + + m_started = true; + + std::vector<TitleInfo*> titlesToConvert; + if (m_titleInfo_base.IsValid()) + titlesToConvert.emplace_back(&m_titleInfo_base); + if (m_titleInfo_update.IsValid()) + titlesToConvert.emplace_back(&m_titleInfo_update); + if (m_titleInfo_aoc.IsValid()) + titlesToConvert.emplace_back(&m_titleInfo_aoc); + + if (titlesToConvert.empty()) + { + callbacks->onError(); + return; + } + + // mount and store + m_writerContext.isValid = true; + m_writerContext.fd = fd; + m_writerContext.zaWriter = std::make_unique<ZArchiveWriter>(&ZArchiveWriterContext::NewOutputFile, &ZArchiveWriterContext::WriteOutputData, &m_writerContext); + if (!m_writerContext.isValid) + { + callbacks->onError(); + return; + } + + bool result = m_writerContext.AddTitles(titlesToConvert.data(), titlesToConvert.size()); + + if (m_writerContext.cancelled) + return; + + if (!result) + { + callbacks->onError(); + return; + } + + m_writerContext.zaWriter->Finalize(); + + // verify the created WUA file + boost::iostreams::stream_buffer<boost::iostreams::file_descriptor_source> stream(fd, boost::iostreams::never_close_handle); + ZArchiveReader* zreader = ZArchiveReader::OpenFromStream(std::make_unique<std::istream>(&stream)); + if (!zreader) + { + callbacks->onError(); + return; + } + // todo - do a quick verification here + delete zreader; + + CafeTitleList::Refresh(); + + callbacks->onFinished(); + }); +} + +uint64 WuaConverter::getTransferredInputBytes() const +{ + return m_writerContext.transferredInputBytes.load(std::memory_order_relaxed); +} + +std::string WuaConverter::getCompressedFileName() +{ + CafeConsoleLanguage languageId = CafeConsoleLanguage::EN; // todo - use user's locale + std::string shortName; + if (m_titleInfo_base.IsValid()) + shortName = m_titleInfo_base.GetMetaInfo()->GetShortName(languageId); + else if (m_titleInfo_update.IsValid()) + shortName = m_titleInfo_update.GetMetaInfo()->GetShortName(languageId); + else if (m_titleInfo_aoc.IsValid()) + shortName = m_titleInfo_aoc.GetMetaInfo()->GetShortName(languageId); + + if (!shortName.empty()) + { + boost::replace_all(shortName, ":", ""); + } + + // get the short name, which we will use as a suggested default file name + std::string defaultFileName = std::move(shortName); + boost::replace_all(defaultFileName, "/", ""); + boost::replace_all(defaultFileName, "\\", ""); + + CafeConsoleRegion region = CafeConsoleRegion::Auto; + if (m_titleInfo_base.IsValid() && m_titleInfo_base.HasValidXmlInfo()) + region = m_titleInfo_base.GetMetaInfo()->GetRegion(); + else if (m_titleInfo_update.IsValid() && m_titleInfo_update.HasValidXmlInfo()) + region = m_titleInfo_update.GetMetaInfo()->GetRegion(); + + if (region == CafeConsoleRegion::JPN) + defaultFileName.append(" (JP)"); + else if (region == CafeConsoleRegion::EUR) + defaultFileName.append(" (EU)"); + else if (region == CafeConsoleRegion::USA) + defaultFileName.append(" (US)"); + if (m_titleInfo_update.IsValid()) + { + defaultFileName.append(fmt::format(" (v{})", m_titleInfo_update.GetAppTitleVersion())); + } + defaultFileName.append(".wua"); + + return defaultFileName; +} + +bool WuaConverter::ZArchiveWriterContext::RecursivelyAddFiles(std::string archivePath, std::string fscPath) +{ + sint32 fscStatus; + std::unique_ptr<FSCVirtualFile> vfDir(fsc_openDirIterator(fscPath.c_str(), &fscStatus)); + if (!vfDir) + return false; + if (cancelled) + return false; + zaWriter->MakeDir(archivePath.c_str(), false); + FSCDirEntry dirEntry; + while (fsc_nextDir(vfDir.get(), &dirEntry)) + { + if (dirEntry.isFile) + { + zaWriter->StartNewFile((archivePath + dirEntry.path).c_str()); + std::unique_ptr<FSCVirtualFile> vFile(fsc_open((fscPath + dirEntry.path).c_str(), FSC_ACCESS_FLAG::OPEN_FILE | FSC_ACCESS_FLAG::READ_PERMISSION, &fscStatus)); + if (!vFile) + return false; + transferBuffer.resize(32 * 1024); // 32KB + uint32 readBytes; + while (true) + { + readBytes = vFile->fscReadData(transferBuffer.data(), transferBuffer.size()); + if (readBytes == 0) + break; + zaWriter->AppendData(transferBuffer.data(), readBytes); + if (cancelled) + return false; + transferredInputBytes += readBytes; + } + } + else if (dirEntry.isDirectory) + { + if (!RecursivelyAddFiles(fmt::format("{}{}/", archivePath, dirEntry.path), fmt::format("{}{}/", fscPath, dirEntry.path))) + return false; + } + else + { + cemu_assert_unimplemented(); + } + } + return true; +} + +void WuaConverter::ZArchiveWriterContext::NewOutputFile(const int32_t partIndex, void* _ctx) +{ + auto ctx = (ZArchiveWriterContext*)_ctx; + ctx->sink = std::make_unique<boost::iostreams::file_descriptor_sink>(ctx->fd, boost::iostreams::never_close_handle); + ctx->isValid = ctx->sink->is_open(); +} + +void WuaConverter::ZArchiveWriterContext::WriteOutputData(const void* data, size_t length, void* _ctx) +{ + auto* ctx = (ZArchiveWriterContext*)_ctx; + if (ctx->isValid) + ctx->sink->write(reinterpret_cast<const char*>(data), length); +} + +bool WuaConverter::ZArchiveWriterContext::StoreTitle(TitleInfo* titleInfo) +{ + std::string temporaryMountPath = TitleInfo::GetUniqueTempMountingPath(); + titleInfo->Mount(temporaryMountPath, "", FSC_PRIORITY_BASE); + bool r = RecursivelyAddFiles(fmt::format("{:016x}_v{}/", titleInfo->GetAppTitleId(), titleInfo->GetAppTitleVersion()), temporaryMountPath); + titleInfo->Unmount(temporaryMountPath); + return r; +} + +bool WuaConverter::ZArchiveWriterContext::AddTitles(TitleInfo** titles, size_t count) +{ + // store files + for (size_t i = 0; i < count; i++) + { + if (!StoreTitle(titles[i])) + return false; + } + return true; +} diff --git a/src/android/app/src/main/cpp/WuaConverter.h b/src/android/app/src/main/cpp/WuaConverter.h new file mode 100644 index 00000000..fb7432dc --- /dev/null +++ b/src/android/app/src/main/cpp/WuaConverter.h @@ -0,0 +1,57 @@ +#pragma once + +#include "Cafe/TitleList/TitleInfo.h" +#include "Cafe/TitleList/TitleList.h" +#include "JNIUtils.h" +#include "CompressTitleCallbacks.h" + +#include <boost/iostreams/device/file_descriptor.hpp> +#include <boost/iostreams/stream_buffer.hpp> +#include <zarchive/zarchivewriter.h> +#include <zarchive/zarchivereader.h> + +class WuaConverter +{ + TitleInfo m_titleInfo_base; + TitleInfo m_titleInfo_update; + TitleInfo m_titleInfo_aoc; + + struct ZArchiveWriterContext + { + static void NewOutputFile(sint32 partIndex, void* _ctx); + + static void WriteOutputData(const void* data, size_t length, void* _ctx); + + bool RecursivelyAddFiles(std::string archivePath, std::string fscPath); + + bool StoreTitle(TitleInfo* titleInfo); + + bool AddTitles(TitleInfo** titles, size_t count); + + int fd; + bool isValid{false}; + std::unique_ptr<boost::iostreams::file_descriptor_sink> sink{}; + std::unique_ptr<ZArchiveWriter> zaWriter{}; + std::vector<uint8> transferBuffer; + std::atomic_bool cancelled{false}; + // progress + std::atomic_uint64_t transferredInputBytes{}; + } m_writerContext; + + std::thread m_workerThread; + bool m_started{false}; + + public: + WuaConverter( + const TitleInfo& titleInfo_base, + const TitleInfo& titleInfo_update, + const TitleInfo& titleInfo_aoc); + + ~WuaConverter(); + + std::string getCompressedFileName(); + + uint64 getTransferredInputBytes() const; + + void startConversion(int fd, std::unique_ptr<CompressTitleCallbacks>&& callbacks); +}; \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/MainActivity.kt b/src/android/app/src/main/java/info/cemu/cemu/MainActivity.kt index eab58b71..9f9ef5d7 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/MainActivity.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/MainActivity.kt @@ -41,6 +41,7 @@ import info.cemu.cemu.graphicpacks.GraphicPacksActivity import info.cemu.cemu.guicore.ActivityContent import info.cemu.cemu.nativeinterface.NativeGameTitles.Game import info.cemu.cemu.settings.SettingsActivity +import info.cemu.cemu.titlemanager.TitleManagerActivity import kotlinx.serialization.Serializable class MainActivity : ComponentActivity() { @@ -137,19 +138,38 @@ fun GameListToolBarActions() { onDismissRequest = { expandMenu = false } ) { DropdownMenuItem( - onClick = { context.goToActivity(SettingsActivity::class.java) }, + onClick = { + context.goToActivity(SettingsActivity::class.java) + expandMenu = false + }, text = { Text(stringResource(R.string.settings)) }, ) DropdownMenuItem( - onClick = { context.goToActivity(GraphicPacksActivity::class.java) }, + onClick = { + context.goToActivity(GraphicPacksActivity::class.java) + expandMenu = false + }, text = { Text(stringResource(R.string.graphic_packs)) }, ) DropdownMenuItem( - onClick = { openCemuFolder(context) }, + onClick = { + context.goToActivity(TitleManagerActivity::class.java) + expandMenu = false + }, + text = { Text(stringResource(R.string.title_manager)) }, + ) + DropdownMenuItem( + onClick = { + openCemuFolder(context) + expandMenu = false + }, text = { Text(stringResource(R.string.open_cemu_folder)) }, ) DropdownMenuItem( - onClick = { context.goToActivity(AboutCemuActivity::class.java) }, + onClick = { + context.goToActivity(AboutCemuActivity::class.java) + expandMenu = false + }, text = { Text(stringResource(R.string.about_cemu)) }, ) } diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListViewModel.kt index 25785dd4..e1332b7f 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListViewModel.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import info.cemu.cemu.nativeinterface.NativeGameTitles import info.cemu.cemu.nativeinterface.NativeGameTitles.Game +import info.cemu.cemu.nativeinterface.NativeSettings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn class GameListViewModel : ViewModel() { + private var gamePaths = NativeSettings.getGamesPaths().toSet() private val _filterText = MutableStateFlow("") private val filterText = _filterText.asStateFlow() fun setFilterText(filterText: String) { @@ -80,6 +82,15 @@ class GameListViewModel : ViewModel() { NativeGameTitles.setGameTitleLoadedCallback(null) } + fun checkIfGamePathsHaveChanged(): Boolean { + val newGamePaths = NativeSettings.getGamesPaths().toSet() + if (newGamePaths != gamePaths) { + gamePaths = newGamePaths + return true + } + return false + } + fun refreshGames() { _games.value = emptySet() NativeGameTitles.reloadGameTitles() diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GamesListScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GamesListScreen.kt index b3a2d519..277f6f71 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GamesListScreen.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GamesListScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -96,6 +97,12 @@ fun GamesListScreen( val state = rememberPullToRefreshState() + LaunchedEffect(Unit) { + if (gameListViewModel.checkIfGamePathsHaveChanged()) { + gameListViewModel.refreshGames() + } + } + DisposableEffect(LocalLifecycleOwner.current) { onDispose { gameListViewModel.setFilterText("") diff --git a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksDownloader.kt b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksDownloader.kt index ba5bb846..2e64d40a 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksDownloader.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksDownloader.kt @@ -2,7 +2,7 @@ package info.cemu.cemu.graphicpacks import android.content.Context import info.cemu.cemu.nativeinterface.NativeGraphicPacks -import info.cemu.cemu.zip.unzip +import info.cemu.cemu.utils.unzip import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient diff --git a/src/android/app/src/main/java/info/cemu/cemu/guicore/Formatters.kt b/src/android/app/src/main/java/info/cemu/cemu/guicore/Formatters.kt new file mode 100644 index 00000000..a094a449 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/guicore/Formatters.kt @@ -0,0 +1,13 @@ +package info.cemu.cemu.guicore + +import android.text.format.Formatter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalContext + +@ReadOnlyComposable +@Composable +fun Long.formatBytes(): String { + val context = LocalContext.current + return Formatter.formatFileSize(context, this) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputDrawable.kt b/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputDrawable.kt index 1f2060cf..295b28bf 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputDrawable.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputDrawable.kt @@ -5,7 +5,7 @@ import android.graphics.Rect import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.core.content.res.ResourcesCompat -import info.cemu.cemu.drawable.getInvertedDrawable +import info.cemu.cemu.utils.getInvertedDrawable class InputDrawable( resources: Resources, diff --git a/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputOverlayDefaultConfigParser.kt b/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputOverlayDefaultConfigParser.kt index 11ccbe23..541f920f 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputOverlayDefaultConfigParser.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/inputoverlay/InputOverlayDefaultConfigParser.kt @@ -1,5 +1,6 @@ package info.cemu.cemu.inputoverlay +import info.cemu.cemu.utils.toIntOrZero import org.xmlpull.v1.XmlPullParser data class InputConfig( @@ -11,8 +12,6 @@ data class InputConfig( val paddingVertical: Int, ) -private fun String.toIntOrZero() = toIntOrNull() ?: 0 - private const val INPUT_OVERLAY_CONFIG_TAG_NAME = "input-overlay-config" private const val WIDTH_CONFIG_TAG_NAME = "width" private const val SIZE_CONFIG_TAG_NAME = "size" diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeActiveSettings.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeActiveSettings.kt index 562b4267..ecde9c49 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeActiveSettings.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeActiveSettings.kt @@ -3,4 +3,7 @@ package info.cemu.cemu.nativeinterface object NativeActiveSettings { @JvmStatic external fun getMLCPath(): String + + @JvmStatic + external fun getUserDataPath(): String } diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeFiles.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeFiles.kt index a010f733..45f41603 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeFiles.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeFiles.kt @@ -42,9 +42,7 @@ fun String.fromNativePath(): Uri { @Keep fun openContentUri(uri: String): Int { try { - if (!exists(uri)) { - return -1 - } + Log.i("NATIVE_FILES", "openContentUri $uri") val parcelFileDescriptor = ContentResolver.openFileDescriptor( uri.fromNativePath(), MODE @@ -62,6 +60,8 @@ fun openContentUri(uri: String): Int { @Keep fun listFiles(uri: String): Array<String?> { + Log.i("NATIVE_FILES", "listFiles $uri") + val files = ArrayList<String>() val directoryUri = uri.fromNativePath() val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( @@ -93,6 +93,8 @@ fun listFiles(uri: String): Array<String?> { @Keep fun isDirectory(uri: String): Boolean { + Log.i("NATIVE_FILES", "isDirectory $uri") + val mimeType = ContentResolver.getType( uri.fromNativePath() ) @@ -106,6 +108,8 @@ fun isFile(uri: String): Boolean { @Keep fun exists(uri: String): Boolean { + Log.i("NATIVE_FILES", "exists $uri") + try { ContentResolver.query(uri.fromNativePath(), null, null, null, null).use { cursor -> return cursor != null && cursor.moveToFirst() diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGameTitles.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGameTitles.kt index 9439c552..834e5246 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGameTitles.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGameTitles.kt @@ -108,4 +108,119 @@ object NativeGameTitles { fun interface GameTitleLoadedCallback { fun onGameTitleLoaded(game: Game?) } + + @Keep + data class SaveData( + val name: String, + val path: String, + val titleId: Long, + val locationUID: Long, + val version: Short, + val region: Int, + ) + + @Keep + fun interface SaveListCallback { + fun onSaveDiscovered(saveData: SaveData) + } + + @JvmStatic + external fun setSaveListCallback(saveListCallback: SaveListCallback?) + + const val TITLE_TYPE_UNKNOWN: Int = 0xFF; + const val TITLE_TYPE_BASE_TITLE: Int = 0x00; + const val TITLE_TYPE_BASE_TITLE_DEMO: Int = 0x02; + const val TITLE_TYPE_BASE_TITLE_UPDATE: Int = 0x0E; + const val TITLE_TYPE_HOMEBREW: Int = 0x0F; + const val TITLE_TYPE_AOC: Int = 0x0C; + const val TITLE_TYPE_SYSTEM_TITLE: Int = 0x10; + const val TITLE_TYPE_SYSTEM_DATA: Int = 0x1B; + const val TITLE_TYPE_SYSTEM_OVERLAY_TITLE: Int = 0x30; + + const val TITLE_DATA_FORMAT_HOST_FS: Int = 1; + const val TITLE_DATA_FORMAT_WUD: Int = 2; + const val TITLE_DATA_FORMAT_WIIU_ARCHIVE: Int = 3; + const val TITLE_DATA_FORMAT_NUS: Int = 4; + const val TITLE_DATA_FORMAT_WUHB: Int = 5; + const val TITLE_DATA_FORMAT_INVALID_STRUCTURE: Int = 0; + + @Keep + data class TitleData( + val name: String, + val path: String, + val titleId: Long, + val locationUID: Long, + val version: Short, + val region: Int, + val titleType: Int, + val titleDataFormat: Int, + ) + + @Keep + interface TitleListCallbacks { + fun onTitleDiscovered(titleData: TitleData) + fun onTitleRemoved(locationUID: Long) + } + + @JvmStatic + external fun refreshCafeTitleList() + + @JvmStatic + external fun setTitleListCallbacks(titleListCallbacks: TitleListCallbacks?) + + @Keep + sealed class TitleExistsError { + data object None : TitleExistsError() + data class DifferentType(val oldType: Int, val toInstallType: Int) : TitleExistsError() + data object SameVersion : TitleExistsError() + data object NewVersion : TitleExistsError() + } + + @Keep + data class TitleExistsStatus(val existsError: TitleExistsError, val targetLocation: String) + + @JvmStatic + external fun checkIfTitleExists(metaPath: String): TitleExistsStatus? + + @JvmStatic + external fun addTitleFromPath(path: String) + + @Keep + fun interface TitleIdToTitlesCallback { + data class Title(val version: Short, val titleUID: Long) + + fun getTitlesByTitleId(titleId: Long): Array<Title> + } + + @Keep + data class CompressTitleInfo( + val basePrintPath: String?, + val updatePrintPath: String?, + val aocPrintPath: String?, + ) + + @JvmStatic + external fun queueTitleToCompress( + titleId: Long, + selectedUID: Long, + titlesCallback: TitleIdToTitlesCallback, + ): CompressTitleInfo + + @JvmStatic + external fun getCompressedFileNameForQueuedTitle(): String? + + @Keep + interface TitleCompressCallbacks { + fun onFinished() + fun onError() + } + + @JvmStatic + external fun compressQueuedTitle(fd: Int, compressCallbacks: TitleCompressCallbacks) + + @JvmStatic + external fun getCurrentProgressForCompression(): Long + + @JvmStatic + external fun cancelTitleCompression() } diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleEntry.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleEntry.kt new file mode 100644 index 00000000..93b10706 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleEntry.kt @@ -0,0 +1,53 @@ +package info.cemu.cemu.titlemanager + +import info.cemu.cemu.nativeinterface.NativeGameTitles + +data class TitleEntry( + val titleId: Long, + val name: String, + val path: String, + val isInMLC: Boolean, + val locationUID: Long, + val version: Short, + val region: Int, + val type: EntryType, + val format: EntryFormat, +) { + init { + if (type == EntryType.Save || format == EntryFormat.SaveFolder) { + require(type == EntryType.Save && format == EntryFormat.SaveFolder) + } + } +} + +enum class EntryType { + Base, + Update, + Dlc, + Save, + System, +} + +fun nativeTitleTypeToEnum(titleType: Int) = when (titleType) { + NativeGameTitles.TITLE_TYPE_BASE_TITLE_UPDATE -> EntryType.Update + NativeGameTitles.TITLE_TYPE_AOC -> EntryType.Dlc + NativeGameTitles.TITLE_TYPE_SYSTEM_OVERLAY_TITLE, NativeGameTitles.TITLE_TYPE_SYSTEM_DATA, NativeGameTitles.TITLE_TYPE_SYSTEM_TITLE -> EntryType.System + else -> EntryType.Base +} + +enum class EntryFormat { + SaveFolder, + Folder, + WUD, + NUS, + WUA, + WUHB, +} + +fun nativeTitleFormatToEnum(titleFormat: Int) = when (titleFormat) { + NativeGameTitles.TITLE_DATA_FORMAT_WUD -> EntryFormat.WUD + NativeGameTitles.TITLE_DATA_FORMAT_WIIU_ARCHIVE -> EntryFormat.WUA + NativeGameTitles.TITLE_DATA_FORMAT_NUS -> EntryFormat.NUS + NativeGameTitles.TITLE_DATA_FORMAT_WUHB -> EntryFormat.WUHB + else -> EntryFormat.Folder +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleInstaller.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleInstaller.kt new file mode 100644 index 00000000..877d34f2 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleInstaller.kt @@ -0,0 +1,15 @@ +package info.cemu.cemu.titlemanager + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import info.cemu.cemu.utils.copyInputStreamToFile +import info.cemu.cemu.utils.urlDecode +import java.nio.file.Path +import java.util.LinkedList +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.math.max + diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleListViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleListViewModel.kt new file mode 100644 index 00000000..19bba2f0 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleListViewModel.kt @@ -0,0 +1,585 @@ +package info.cemu.cemu.titlemanager + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.SystemClock +import android.provider.DocumentsContract +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import info.cemu.cemu.nativeinterface.NativeActiveSettings +import info.cemu.cemu.nativeinterface.NativeGameTitles +import info.cemu.cemu.nativeinterface.NativeGameTitles.TitleIdToTitlesCallback.Title +import info.cemu.cemu.nativeinterface.fromNativePath +import info.cemu.cemu.utils.copyInputStreamToFile +import info.cemu.cemu.utils.isContentUri +import info.cemu.cemu.utils.urlDecode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import java.io.File +import java.nio.file.Path +import java.util.LinkedList +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.relativeToOrNull +import kotlin.math.max +import kotlin.random.Random +import kotlin.random.nextUInt + +enum class EntryPath { + MLC, + GamePaths, +} + +data class TitleListFilter( + val query: String, + val types: Set<EntryType>, + val formats: Set<EntryFormat>, + val paths: Set<EntryPath>, +) + +interface TitleDeleteCallbacks { + fun onDeleteFinished() + fun onError() +} + +interface TitleInstallCallbacks { + fun onInstallFinished() + fun onError() +} + +class FilterActions<T>( + private val currentFilters: () -> Set<T>, + private val updateFilters: (Set<T>) -> Unit, +) { + fun add(filter: T) { + updateFilters(currentFilters() + filter) + } + + fun remove(filter: T) { + updateFilters(currentFilters() - filter) + } +} + +class TitleListViewModel : ViewModel() { + private val mlcPath = Path(NativeActiveSettings.getMLCPath()) + private fun isPathInMLC(path: String): Boolean = + Path(path).relativeToOrNull(mlcPath)?.let { it.startsWith("sys") || it.startsWith("usr") } + ?: false + + private val _filter = MutableStateFlow( + TitleListFilter( + query = "", + types = EntryType.entries.toSet(), + formats = EntryFormat.entries.toSet(), + paths = EntryPath.entries.toSet(), + ) + ) + val filter = _filter.asStateFlow() + + fun setFilterQuery(query: String) { + _filter.value = _filter.value.copy(query = query) + } + + val typesFilter = + FilterActions( + { _filter.value.types }, + { _filter.value = _filter.value.copy(types = it) }) + + val formatsFilter = + FilterActions( + { _filter.value.formats }, + { _filter.value = _filter.value.copy(formats = it) }) + + val pathsFilter = + FilterActions( + { _filter.value.paths }, + { _filter.value = _filter.value.copy(paths = it) }) + + private val _titleEntries = MutableStateFlow<List<TitleEntry>>(emptyList()) + val titleEntries = filter.combine(_titleEntries) { filter, entries -> + entries.filter { entry -> + val isTypeMatching = entry.type in filter.types + val isFormatMatching = entry.format in filter.formats + val isPathMatching = when { + entry.isInMLC -> EntryPath.MLC in filter.paths + else -> EntryPath.GamePaths in filter.paths + } + val isQueryMatching = + filter.query.isBlank() || entry.name.contains(filter.query, ignoreCase = true) + isTypeMatching && isFormatMatching && isPathMatching && isQueryMatching + }.sortedBy { it.name } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + private val titleListCallbacks = object : NativeGameTitles.TitleListCallbacks { + override fun onTitleDiscovered(titleData: NativeGameTitles.TitleData) { + addTitle(titleData.toTitleEntry(isPathInMLC(titleData.path))) + } + + override fun onTitleRemoved(locationUID: Long) { + _titleEntries.value = + _titleEntries.value.toMutableList() + .apply { + val index = indexOfFirst { it.locationUID == locationUID } + if (index >= 0) removeAt(index) + } + } + } + + private val saveListCallback = NativeGameTitles.SaveListCallback { saveData -> + addTitle(saveData.toTitleEntry(isPathInMLC(saveData.path))) + } + + private var lastRefreshTime = 0L + fun refresh() { + if (SystemClock.elapsedRealtime() - lastRefreshTime >= REFRESH_DEBOUNCE_TIME_MILLISECONDS) { + lastRefreshTime = SystemClock.elapsedRealtime() + NativeGameTitles.refreshCafeTitleList() + } + } + + init { + NativeGameTitles.setTitleListCallbacks(titleListCallbacks) + NativeGameTitles.setSaveListCallback(saveListCallback) + } + + override fun onCleared() { + super.onCleared() + NativeGameTitles.setTitleListCallbacks(null) + NativeGameTitles.setSaveListCallback(null) + } + + private fun addTitle(titleEntry: TitleEntry) { + if (_titleEntries.value.any { it.locationUID == titleEntry.locationUID }) return + _titleEntries.value += titleEntry + } + + private val _titleToBeDeleted = MutableStateFlow<TitleEntry?>(null) + val titleToBeDeleted = _titleToBeDeleted.asStateFlow() + fun deleteTitleEntry( + titleEntry: TitleEntry, + context: Context, + deleteCallbacks: TitleDeleteCallbacks, + ) { + if (_titleToBeDeleted.value != null) + return + + if (!_titleEntries.value.any { it.locationUID == titleEntry.locationUID }) { + deleteCallbacks.onDeleteFinished() + return + } + + _titleToBeDeleted.value = titleEntry + + viewModelScope.launch { + try { + if (!delete(context.contentResolver, titleEntry.path)) { + deleteCallbacks.onError() + return@launch + } + + _titleEntries.value = + _titleEntries.value.toMutableList().also { titleEntriesList -> + titleEntriesList.removeIf { it.locationUID == titleEntry.locationUID || it.path == titleEntry.path } + } + deleteCallbacks.onDeleteFinished() + } catch (e: Exception) { + deleteCallbacks.onError() + } finally { + _titleToBeDeleted.value = null + } + } + } + + private val _queuedTitleToInstall = + MutableStateFlow<Pair<Uri, NativeGameTitles.TitleExistsStatus>?>(null) + val queuedTitleToInstallError = + _queuedTitleToInstall.map { it?.second?.existsError }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null + ) + + fun queueTitleToInstall(titlePath: Uri, onInvalidTitle: () -> Unit) { + if (_queuedTitleToInstall.value != null) + return + + val existsStatus = NativeGameTitles.checkIfTitleExists(titlePath.toString()) + if (existsStatus == null) { + onInvalidTitle() + return + } + + _queuedTitleToInstall.value = Pair(titlePath, existsStatus) + } + + fun removedQueuedTitleToInstall() { + _queuedTitleToInstall.value = null + } + + private var installTitleJob: Job? = null + fun cancelInstall() { + installTitleJob?.cancel() + installTitleJob = null + } + + private suspend fun listFilesInSourceDirs( + contentResolver: ContentResolver, + titleDir: DocumentFile, + titleUri: Uri, + targetLocation: String, + ): Pair<Long, LinkedList<DirEntry>> { + val entries = LinkedList<DirEntry>() + var totalSize = 0L + + for (sourceDir in SOURCE_DIRS) { + val parentUri = titleDir.findFile(sourceDir)!!.uri + val parentUriLength = titleUri.toString().length + val uriToTargetPath: (Uri) -> Path = { + val relativePath = it.toString().substring(parentUriLength).urlDecode() + Path(targetLocation, relativePath) + } + + entries += DirEntry.Dir(Path(targetLocation, sourceDir)) + listFilesRecursively( + contentResolver = contentResolver, + dirUri = parentUri, + onFile = { uri, sizeInBytes -> + totalSize += sizeInBytes + entries += DirEntry.File( + uri, + uriToTargetPath(uri), + sizeInBytes + ) + }, + onDir = { entries += DirEntry.Dir(uriToTargetPath(it)) }, + ) + } + + totalSize = max(totalSize, 1L) + + return Pair(totalSize, entries) + } + + private val _titleInstallInProgress = MutableStateFlow(false) + val titleInstallInProgress = _titleInstallInProgress.asStateFlow() + + private val _titleInstallProgress = MutableStateFlow<Pair<Long, Long>?>(null) + val titleInstallProgress = _titleInstallProgress.asStateFlow() + + fun installQueuedTitle( + context: Context, + titleInstallCallbacks: TitleInstallCallbacks, + ) { + if (_titleInstallInProgress.value) + return + + val titleToInstall = _queuedTitleToInstall.value + if (titleToInstall == null) { + titleInstallCallbacks.onInstallFinished() + return + } + _queuedTitleToInstall.value = null + + val titleUri = titleToInstall.first + val targetLocation = titleToInstall.second.targetLocation + + val installPath = Path(targetLocation) + val installFile = installPath.toFile() + val backupFile = installPath.getBackupFile() + + _titleInstallProgress.value = null + _titleInstallInProgress.value = true + + val oldInstallJob = installTitleJob + installTitleJob = viewModelScope.launch { + var installStarted = false + var installFinished = false + try { + cleanupJob?.join() + oldInstallJob?.join() + + withContext(Dispatchers.IO) { + val contentResolver = context.contentResolver + val buffer = ByteArray(8192) + var bytesWritten = 0L + + val (totalSize, entries) = listFilesInSourceDirs( + contentResolver = contentResolver, + titleDir = DocumentFile.fromTreeUri(context, titleUri)!!, + titleUri = titleUri, + targetLocation = targetLocation, + ) + + if (totalSize > mlcPath.toFile().freeSpace) { + titleInstallCallbacks.onError() + return@withContext + } + + backupFile.deleteRecursively() + if (installFile.exists()) + installFile.renameTo(backupFile) + installStarted = true + + for (file in entries) { + yield() + when (file) { + is DirEntry.Dir -> { + file.destinationPath.createDirectories() + } + + is DirEntry.File -> contentResolver.openInputStream(file.uri)?.use { + copyInputStreamToFile(it, file.destinationPath, buffer) + bytesWritten += file.sizeInBytes + _titleInstallProgress.value = Pair(bytesWritten, totalSize) + } + } + } + + if (backupFile.exists()) + backupFile.deleteRecursively() + + installFinished = true + } + + if (installFinished) { + NativeGameTitles.addTitleFromPath(targetLocation) + titleInstallCallbacks.onInstallFinished() + } + } catch (exception: Exception) { + Log.e("TITLE_LIST", "Failed to install ${exception.message}") + if (installStarted) + cleanupInstall(installFile) + + if (exception !is CancellationException) + titleInstallCallbacks.onError() + } finally { + _titleInstallInProgress.value = false + } + } + } + + private var cleanupJob: Job? = null + private fun cleanupInstall(installFile: File) { + val oldCleanupJob = cleanupJob + cleanupJob = viewModelScope.launch { + oldCleanupJob?.join() + withContext(Dispatchers.IO) { + val installPath = installFile.toPath() + var tempFile: File? = null + if (installFile.exists()) { + val tempName = "${installPath.fileName}-${Random.nextUInt()}" + tempFile = installPath.resolveSibling(tempName).toFile() + installFile.renameTo(tempFile!!) + } + val backupInstall = installPath.getBackupFile() + if (backupInstall.exists()) + backupInstall.renameTo(installFile) + tempFile?.deleteRecursively() + } + } + } + + private val _queuedTitleToCompress = + MutableStateFlow<NativeGameTitles.CompressTitleInfo?>(null) + val queuedTitleToCompress = _queuedTitleToCompress.asStateFlow() + fun queueTitleForCompression(titleEntry: TitleEntry) { + require( + value = titleEntry.type != EntryType.Save && titleEntry.format != EntryFormat.WUA, + lazyMessage = { "Invalid title queued for compression. ${titleEntry.name} (${titleEntry.type.name}) (${titleEntry.format.name})" } + ) + + _queuedTitleToCompress.value = NativeGameTitles.queueTitleToCompress( + titleId = titleEntry.titleId, + selectedUID = titleEntry.locationUID, + titlesCallback = { titleId -> + titleEntries.value.filter { it.titleId == titleId } + .map { Title(it.version, it.locationUID) } + .toTypedArray() + }) + } + + fun removeQueuedTitleForCompression() { + _queuedTitleToCompress.value = null + } + + private val _compressProgress = MutableStateFlow<Long?>(null) + val compressProgress = _compressProgress.asStateFlow() + private var compressProgressJob: Job? = null + + private val _compressInProgress = MutableStateFlow(false) + val compressInProgress = _compressInProgress.asStateFlow() + + fun compressQueuedTitle( + context: Context, + uri: Uri, + onFinished: () -> Unit, + onError: () -> Unit, + ) { + if (_compressInProgress.value) + return + + _queuedTitleToCompress.value = null + + val fd = context.contentResolver.openFileDescriptor(uri, "rw") ?: return + + val oldCompressProgressJob = compressProgressJob + compressProgressJob = viewModelScope.launch { + oldCompressProgressJob?.cancelAndJoin() + _compressInProgress.value = true + try { + while (true) { + delay(500) + _compressProgress.value = NativeGameTitles.getCurrentProgressForCompression() + } + } catch (_: CancellationException) { + } finally { + _compressInProgress.value = false + _compressProgress.value = null + } + } + + NativeGameTitles.compressQueuedTitle( + fd = fd.detachFd(), + compressCallbacks = object : NativeGameTitles.TitleCompressCallbacks { + override fun onFinished() { + compressProgressJob?.cancel() + onFinished() + } + + override fun onError() { + compressProgressJob?.cancel() + onError() + } + }) + } + + fun cancelCompression() { + viewModelScope.launch { + compressProgressJob?.cancelAndJoin() + withContext(Dispatchers.IO) { + NativeGameTitles.cancelTitleCompression() + } + } + } + + fun getCompressedFileNameForQueuedTitle() = + NativeGameTitles.getCompressedFileNameForQueuedTitle() + + companion object { + private const val REFRESH_DEBOUNCE_TIME_MILLISECONDS = 1500L + private val SOURCE_DIRS = arrayOf( + "content", + "code", + "meta", + ) + } +} + +private suspend fun delete(contentResolver: ContentResolver, path: String): Boolean { + return withContext(Dispatchers.IO) { + if (path.isContentUri()) + return@withContext DocumentsContract.deleteDocument( + contentResolver, + path.fromNativePath() + ) + + return@withContext Path(path).toFile().deleteRecursively() + } +} + +private fun NativeGameTitles.SaveData.toTitleEntry(isInMlc: Boolean) = TitleEntry( + titleId = this.titleId, + name = this.name, + path = this.path, + isInMLC = isInMlc, + locationUID = this.locationUID, + version = this.version, + region = this.region, + type = EntryType.Save, + format = EntryFormat.SaveFolder, +) + +private fun NativeGameTitles.TitleData.toTitleEntry(isInMlc: Boolean) = TitleEntry( + titleId = this.titleId, + name = this.name, + path = this.path, + isInMLC = isInMlc, + locationUID = this.locationUID, + version = this.version, + region = this.region, + type = nativeTitleTypeToEnum(this.titleType), + format = nativeTitleFormatToEnum(this.titleDataFormat), +) + +private fun Path.getBackupFile() = resolveSibling("$fileName.backup").toFile() + +private sealed class DirEntry { + data class File(val uri: Uri, val destinationPath: Path, val sizeInBytes: Long) : DirEntry() + data class Dir(val destinationPath: Path) : DirEntry() +} + +private suspend fun listFilesRecursively( + contentResolver: ContentResolver, + dirUri: Uri, + onFile: (Uri, Long) -> Unit, + onDir: (Uri) -> Unit, +) { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + dirUri, + DocumentsContract.getDocumentId(dirUri) + ) + val cursor = contentResolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ), + null, + null, + null + ) + cursor?.use { + while (it.moveToNext()) { + yield() + + val documentId = it.getString(0) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(dirUri, documentId) + + val mimeType = it.getString(2) + if (mimeType != DocumentsContract.Document.MIME_TYPE_DIR) { + val sizeInBytes = it.getLong(1) + onFile(documentUri, sizeInBytes) + continue + } + + onDir(documentUri) + + listFilesRecursively( + contentResolver, + DocumentsContract.buildDocumentUriUsingTree(dirUri, documentId), + onFile, + onDir, + ) + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerActivity.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerActivity.kt new file mode 100644 index 00000000..96524854 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerActivity.kt @@ -0,0 +1,18 @@ +package info.cemu.cemu.titlemanager + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import info.cemu.cemu.guicore.ActivityContent + + +class TitleManagerActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ActivityContent { + TitleManagerScreen(::onNavigateUp) + } + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerScreen.kt new file mode 100644 index 00000000..d9504b05 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerScreen.kt @@ -0,0 +1,849 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) + +package info.cemu.cemu.titlemanager + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.R +import info.cemu.cemu.guicore.ScreenContentLazy +import info.cemu.cemu.guicore.enumtostringmapper.native.regionToStringId +import info.cemu.cemu.guicore.formatBytes +import info.cemu.cemu.nativeinterface.NativeGameTitles +import kotlinx.coroutines.launch + +@Composable +fun TitleManagerScreen( + navigateBack: () -> Unit, + titleListViewModel: TitleListViewModel = viewModel(), +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + var showFilterSheet by remember { mutableStateOf(false) } + val titleEntries by titleListViewModel.titleEntries.collectAsState() + val queuedTitleToInstallError by titleListViewModel.queuedTitleToInstallError.collectAsState() + val queuedTitleToCompress by titleListViewModel.queuedTitleToCompress.collectAsState() + val showTitleInstallProgress by titleListViewModel.titleInstallInProgress.collectAsState() + val titleInstallProgress by titleListViewModel.titleInstallProgress.collectAsState() + val compressTitleInProgress by titleListViewModel.compressInProgress.collectAsState() + val currentCompressProgress by titleListViewModel.compressProgress.collectAsState() + val titleToBeDeleted by titleListViewModel.titleToBeDeleted.collectAsState() + + fun showNotificationMessage(@StringRes stringId: Int) { + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(context.getString(stringId)) + } + } + + fun installQueuedTitle() { + titleListViewModel.installQueuedTitle( + context = context, + titleInstallCallbacks = object : TitleInstallCallbacks { + override fun onInstallFinished() { + showNotificationMessage(R.string.install_title_finished) + } + + override fun onError() { + showNotificationMessage(R.string.install_title_error) + } + } + ) + } + + val installTitleLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + val documentFile = + DocumentFile.fromTreeUri(context, uri) ?: return@rememberLauncherForActivityResult + titleListViewModel.queueTitleToInstall( + titlePath = documentFile.uri, + onInvalidTitle = { + showNotificationMessage(R.string.install_title_invalid_title) + }) + } + + val compressFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + titleListViewModel.compressQueuedTitle( + context = context, + uri = uri, + onFinished = { + showNotificationMessage(R.string.wua_convert_finished) + }, + onError = { + showNotificationMessage(R.string.wua_convert_error) + } + ) + } + + ScreenContentLazy( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + appBarText = stringResource(R.string.title_manager_screen_label), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { + titleListViewModel.refresh() + showNotificationMessage(R.string.titles_refreshing_notification) + }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.title_manager_refresh_titles_description), + ) + } + IconButton(onClick = { showFilterSheet = true }) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = stringResource(R.string.title_manager_filter_titles_description), + ) + } + IconButton(onClick = { installTitleLauncher.launch(null) }) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.title_manager_install_title_description) + ) + } + } + ) { + items(items = titleEntries, key = { it.locationUID }) { + TitleEntryListItem( + titleEntry = it, + onDeleteRequest = { + titleListViewModel.deleteTitleEntry( + titleEntry = it, + context = context, + deleteCallbacks = object : TitleDeleteCallbacks { + override fun onDeleteFinished() { + showNotificationMessage(R.string.title_entry_delete_notification) + } + + override fun onError() { + showNotificationMessage(R.string.title_entry_failed_delete_notification) + } + }) + }, + onCompressRequested = { + titleListViewModel.queueTitleForCompression(it) + } + ) + } + } + if (showFilterSheet) + TitleFilterBottomSheet( + onDismissRequest = { showFilterSheet = false }, + titleListViewModel = titleListViewModel + ) + + queuedTitleToInstallError?.let { titleToInstallError -> + TitleInstallConfirmDialog( + error = titleToInstallError, + onDismissRequest = { titleListViewModel.removedQueuedTitleToInstall() }, + onConfirm = { installQueuedTitle() } + ) + } + + if (showTitleInstallProgress) + TitleInstallProgressDialog( + progress = titleInstallProgress, + onCancel = { + titleListViewModel.cancelInstall() + } + ) + + queuedTitleToCompress?.let { titleToCompressInfo -> + TitleCompressConfirmationDialog( + onDismiss = { titleListViewModel.removeQueuedTitleForCompression() }, + onConfirm = { + val fileName = titleListViewModel.getCompressedFileNameForQueuedTitle() + ?: return@TitleCompressConfirmationDialog + compressFileLauncher.launch(fileName) + }, + compressTitleInfo = titleToCompressInfo, + ) + } + + if (compressTitleInProgress) + TitleCompressProgressDialog( + bytesWritten = currentCompressProgress, + onCancel = titleListViewModel::cancelCompression, + ) + + titleToBeDeleted?.let { titleEntry -> + DeleteTitleProgressDialog(titleEntry) + } +} + +@Composable +private fun TitleCompressProgressDialog(bytesWritten: Long?, onCancel: () -> Unit) { + var showCancelConfirmDialog by remember { mutableStateOf(false) } + + AlertDialog( + title = { Text(stringResource(R.string.wua_convert_title)) }, + text = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator() + if (bytesWritten != null) + Text( + stringResource( + R.string.wua_convert_current_progress, + bytesWritten.formatBytes() + ) + ) + } + }, + onDismissRequest = {}, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = { showCancelConfirmDialog = true }, + content = { Text(stringResource(R.string.cancel)) }, + ) + } + ) + + if (showCancelConfirmDialog) + AlertDialog( + title = { Text(stringResource(R.string.wua_convert_cancel_title)) }, + text = { Text(stringResource(R.string.wua_convert_cancel_text)) }, + onDismissRequest = { showCancelConfirmDialog = false }, + confirmButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showCancelConfirmDialog = false }) { + Text(stringResource(R.string.no)) + } + } + ) +} + +@Composable +private fun TitleCompressConfirmationDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + compressTitleInfo: NativeGameTitles.CompressTitleInfo, +) { + @Composable + fun EntryInfo(entryName: String, entryPrintPath: String?) { + Text( + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + text = entryName, + ) + Text( + modifier = Modifier.padding(bottom = 4.dp), + fontSize = 14.sp, + text = entryPrintPath ?: stringResource(R.string.wua_convert_title_not_installed) + ) + } + AlertDialog( + title = { Text(stringResource(R.string.wua_convert_confirmation_title)) }, + text = { + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + text = stringResource(R.string.wua_convert_confirmation_description), + ) + EntryInfo( + stringResource(R.string.wua_convert_base_game_title_entry), + compressTitleInfo.basePrintPath + ) + EntryInfo( + stringResource(R.string.wua_convert_update_title_entry), + compressTitleInfo.updatePrintPath + ) + EntryInfo( + stringResource(R.string.wua_convert_dlc_title_entry), + compressTitleInfo.aocPrintPath + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} + +@Composable +private fun TitleInstallProgressDialog( + progress: Pair<Long, Long>?, + onCancel: () -> Unit, +) { + var showCancelConfirmDialog by remember { mutableStateOf(false) } + + AlertDialog( + title = { Text(stringResource(R.string.install_title_progress_title)) }, + text = { + val progressModifiers = Modifier + .fillMaxWidth() + .padding(8.dp) + Column { + if (progress == null) { + LinearProgressIndicator(modifier = progressModifiers) + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.install_title_parsing_content) + ) + } else { + val (bytesWritten, maxBytes) = progress + LinearProgressIndicator( + modifier = progressModifiers, + progress = { + bytesWritten.toFloat() / maxBytes.toFloat() + }, + ) + Text( + modifier = Modifier.padding(8.dp), + text = "${bytesWritten.formatBytes()}/${maxBytes.formatBytes()}" + ) + } + } + }, + onDismissRequest = {}, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showCancelConfirmDialog = true }) { + Text(stringResource(R.string.cancel)) + } + } + ) + + if (showCancelConfirmDialog) + AlertDialog( + title = { Text(stringResource(R.string.install_title_cancel_title)) }, + text = { + Text(stringResource(R.string.install_title_cancel_text)) + }, + onDismissRequest = { showCancelConfirmDialog = false }, + confirmButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showCancelConfirmDialog = false }) { + Text(stringResource(R.string.no)) + } + } + ) +} + + +@Composable +private fun TitleInstallConfirmDialog( + error: NativeGameTitles.TitleExistsError, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + val errorMessage = when (error) { + is NativeGameTitles.TitleExistsError.DifferentType -> stringResource( + R.string.title_exists_error_different_type, + error.oldType, + error.toInstallType + ) + + NativeGameTitles.TitleExistsError.NewVersion -> stringResource(R.string.title_exists_error_new_version) + NativeGameTitles.TitleExistsError.SameVersion -> stringResource(R.string.title_exists_error_same_version) + + NativeGameTitles.TitleExistsError.None -> { + onConfirm() + return + } + } + + AlertDialog( + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.warning)) }, + text = { + Text( + text = errorMessage, + modifier = Modifier, + ) + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.yes)) } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.no)) } + } + ) +} + +@Composable +private fun TitleFilterBottomSheet( + onDismissRequest: () -> Unit, + titleListViewModel: TitleListViewModel, +) { + val filter by titleListViewModel.filter.collectAsState() + + ModalBottomSheet( + sheetState = rememberModalBottomSheetState(), + onDismissRequest = onDismissRequest, + ) { + TextField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + singleLine = true, + value = filter.query, + onValueChange = titleListViewModel::setFilterQuery, + label = { Text(stringResource(R.string.title_manager_search_label)) } + ) + FilterRow( + filterRowLabel = stringResource(R.string.title_manager_filter_types_label), + filterValues = EntryType.entries.map { (it to (it in filter.types)) }, + valueToLabel = { stringResource(entryTypeToStringId(it)) }, + onFilterAdded = titleListViewModel.typesFilter::add, + onFilterRemoved = titleListViewModel.typesFilter::remove, + ) + FilterRow( + filterRowLabel = stringResource(R.string.title_manager_filter_formats_label), + filterValues = EntryFormat.entries.map { (it to (it in filter.formats)) }, + valueToLabel = { stringResource(formatToStringId(it)) }, + onFilterAdded = titleListViewModel.formatsFilter::add, + onFilterRemoved = titleListViewModel.formatsFilter::remove, + ) + FilterRow( + filterRowLabel = stringResource(R.string.title_manager_filter_locations_label), + filterValues = EntryPath.entries.map { (it to (it in filter.paths)) }, + valueToLabel = { stringResource(pathToStringId(it)) }, + onFilterAdded = titleListViewModel.pathsFilter::add, + onFilterRemoved = titleListViewModel.pathsFilter::remove, + ) + } +} + +@Composable +private fun <T> FilterRow( + filterRowLabel: String, + filterValues: List<Pair<T, Boolean>>, + valueToLabel: @Composable (T) -> String, + onFilterAdded: (T) -> Unit, + onFilterRemoved: (T) -> Unit, +) { + var showOptions by remember { mutableStateOf(false) } + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .animateContentSize() + ) { + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = { showOptions = !showOptions }) { + Text(text = filterRowLabel, modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier.rotate(if (showOptions) 180f else 0f), + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + ) + } + if (showOptions) + FlowRow( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + filterValues.forEach { (value, selected) -> + FilterChip( + label = valueToLabel(value), + selected = selected, + onAdd = { onFilterAdded(value) }, + onRemove = { onFilterRemoved(value) }, + ) + } + } + } +} + +@Composable +fun FilterChip(label: String, selected: Boolean, onAdd: () -> Unit, onRemove: () -> Unit) { + FilterChip( + leadingIcon = { + if (selected) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null + ) + }, + selected = selected, + onClick = { + if (selected) onRemove() + else onAdd() + }, + label = { Text(label) } + ) +} + +@Composable +private fun TitleEntryListItem( + titleEntry: TitleEntry, + onDeleteRequest: () -> Unit, + onCompressRequested: () -> Unit, +) { + var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .padding(8.dp), + ) { + var showTitleInfo by remember { mutableStateOf(false) } + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TitleEntryIcon(titleEntry.type) + Text( + modifier = Modifier + .padding(horizontal = 4.dp) + .basicMarquee(iterations = Int.MAX_VALUE) + .weight(1.0f), + text = titleEntry.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + TitleDropDownMenu( + titleEntry = titleEntry, + onDeleteClicked = { showDeleteConfirmationDialog = true }, + onCompressClicked = onCompressRequested + ) + IconButton( + onClick = { showTitleInfo = !showTitleInfo }) { + Icon( + modifier = Modifier.rotate(if (showTitleInfo) 180f else 0f), + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = stringResource(R.string.title_manager_show_info_description) + ) + } + } + if (showTitleInfo) { + TitleEntryData(titleEntry) + } + } + + if (showDeleteConfirmationDialog) + DeleteTitleConfirmationDialog( + titleEntry = titleEntry, + onDismissRequest = { showDeleteConfirmationDialog = false }, + onConfirmDelete = onDeleteRequest + ) +} + +@Composable +private fun TitleDropDownMenu( + titleEntry: TitleEntry, + onDeleteClicked: () -> Unit, + onCompressClicked: () -> Unit, +) { + var expandMenu by remember { mutableStateOf(false) } + + @Composable + fun DropdownMenuItem(text: String, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(text) }, + onClick = { + expandMenu = false + onClick() + } + ) + } + + Box { + IconButton(onClick = { expandMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.title_dropdown_menu_description) + ) + } + DropdownMenu( + expanded = expandMenu, + onDismissRequest = { expandMenu = false }) { + DropdownMenuItem(stringResource(R.string.delete), onDeleteClicked) + if (titleEntry.type != EntryType.Save && titleEntry.format != EntryFormat.WUA) + DropdownMenuItem( + stringResource(R.string.wua_convert_action_label), + onCompressClicked + ) + } + } +} + +@Composable +private fun DeleteTitleProgressDialog(titleEntry: TitleEntry) { + AlertDialog( + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { + Text(stringResource(R.string.delete_title_progress_dialog_title)) + }, + text = { + val titleEntryInfo = stringResource( + R.string.title_entry_delete_info, + titleEntry.name, + stringResource(regionToStringId(titleEntry.region)), + stringResource(entryTypeToStringId(titleEntry.type)) + ) + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.delete_title_title_info, titleEntryInfo)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + }, + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {} + ) +} + +@Composable +private fun DeleteTitleConfirmationDialog( + titleEntry: TitleEntry, + onDismissRequest: () -> Unit, + onConfirmDelete: () -> Unit, +) { + AlertDialog( + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.warning)) }, + text = { + val titleEntryInfo = stringResource( + R.string.title_entry_delete_info, + titleEntry.name, + stringResource(regionToStringId(titleEntry.region)), + stringResource(entryTypeToStringId(titleEntry.type)) + ) + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.title_entry_delete_confirmation)) + Text(titleEntryInfo) + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirmDelete() + }, + content = { Text(stringResource(R.string.yes)) }, + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.no)) } + } + ) +} + +@Composable +private fun TitleEntryIcon(entryType: EntryType, modifier: Modifier = Modifier) { + val iconId = when (entryType) { + EntryType.Base -> R.drawable.ic_controller + EntryType.Update -> R.drawable.ic_upgrade + EntryType.Dlc -> R.drawable.ic_box + EntryType.Save -> R.drawable.ic_save + EntryType.System -> R.drawable.ic_build + } + Icon( + modifier = modifier, + painter = painterResource(iconId), + contentDescription = stringResource(entryTypeToStringId(entryType)) + ) +} + +@Composable +private fun TitleEntryData(titleEntry: TitleEntry) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + TitleEntryInfo( + name = stringResource(R.string.title_info_id_label), + value = formatTitleId(titleEntry.titleId) + ) + TitleEntryInfo( + name = stringResource(R.string.title_info_type_label), + value = stringResource(entryTypeToStringId(titleEntry.type)), + ) + TitleEntryInfo( + name = stringResource(R.string.title_info_version_label), + value = titleEntry.version.toString(), + ) + TitleEntryInfo( + name = stringResource(R.string.title_info_region_label), + value = stringResource(regionToStringId(titleEntry.region)), + ) + TitleEntryInfo( + name = stringResource(R.string.title_info_format_label), + value = stringResource(formatToStringId(titleEntry.format)), + ) + TitleEntryInfo( + name = stringResource(R.string.title_info_location_label), + value = if (titleEntry.isInMLC) stringResource(R.string.location_mlc) + else stringResource(R.string.location_game_paths) + ) + } +} + +@Composable +private fun TitleEntryInfo(name: String, value: String) { + Text( + modifier = Modifier + .padding( + top = 2.dp, + start = 8.dp, + end = 8.dp, + ), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + text = name, + ) + Text( + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + bottom = 2.dp, + ), + fontSize = 14.sp, + text = value + ) +} + +@StringRes +private fun formatToStringId(entryFormat: EntryFormat) = when (entryFormat) { + EntryFormat.Folder -> R.string.entry_format_folder + EntryFormat.WUD -> R.string.entry_format_wud + EntryFormat.NUS -> R.string.entry_format_nus + EntryFormat.WUA -> R.string.entry_format_wua + EntryFormat.WUHB -> R.string.entry_format_wuhb + EntryFormat.SaveFolder -> R.string.entry_format_save_folder +} + +@StringRes +private fun pathToStringId(entryPath: EntryPath) = when (entryPath) { + EntryPath.MLC -> R.string.location_mlc + EntryPath.GamePaths -> R.string.location_game_paths +} + +@StringRes +private fun entryTypeToStringId(entryType: EntryType) = when (entryType) { + EntryType.Base -> R.string.entry_type_base + EntryType.Update -> R.string.entry_type_update + EntryType.Dlc -> R.string.entry_type_dlc + EntryType.Save -> R.string.entry_type_save + EntryType.System -> R.string.entry_type_system +} + +private fun formatTitleId(titleId: Long) = + String.format("%08x-%08x", titleId shr 32, titleId and 0xFFFFFFFF) diff --git a/src/android/app/src/main/java/info/cemu/cemu/drawable/Drawables.kt b/src/android/app/src/main/java/info/cemu/cemu/utils/Drawables.kt similarity index 75% rename from src/android/app/src/main/java/info/cemu/cemu/drawable/Drawables.kt rename to src/android/app/src/main/java/info/cemu/cemu/utils/Drawables.kt index f4bdd3e1..3358da70 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/drawable/Drawables.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/utils/Drawables.kt @@ -1,4 +1,4 @@ -package info.cemu.cemu.drawable +package info.cemu.cemu.utils import android.content.res.Resources import android.graphics.ColorMatrix @@ -15,11 +15,11 @@ private val INVERTED_COLOR_MATRIX = ColorMatrix( ) fun Drawable.getInvertedDrawable(resources: Resources?): Drawable { - val newDrawable = this.constantState!!.newDrawable(resources) + val newDrawable = constantState!!.newDrawable(resources) return newDrawable.applyInvertedColorTransform() } fun Drawable.applyInvertedColorTransform(): Drawable { - this.mutate().colorFilter = ColorMatrixColorFilter(INVERTED_COLOR_MATRIX) + mutate().colorFilter = ColorMatrixColorFilter(INVERTED_COLOR_MATRIX) return this } diff --git a/src/android/app/src/main/java/info/cemu/cemu/utils/IO.kt b/src/android/app/src/main/java/info/cemu/cemu/utils/IO.kt new file mode 100644 index 00000000..a6eb922b --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/utils/IO.kt @@ -0,0 +1,13 @@ +package info.cemu.cemu.utils + +import java.io.InputStream +import java.nio.file.Path + +fun copyInputStreamToFile(inputStream: InputStream, filePath: Path, buffer: ByteArray) { + filePath.toFile().outputStream().use { outputStream -> + var bytesRead: Int + while ((inputStream.read(buffer).also { bytesRead = it }) > 0) { + outputStream.write(buffer, 0, bytesRead) + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/utils/String.kt b/src/android/app/src/main/java/info/cemu/cemu/utils/String.kt new file mode 100644 index 00000000..b7668c23 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/utils/String.kt @@ -0,0 +1,9 @@ +package info.cemu.cemu.utils + +import java.net.URLDecoder + +fun String.urlDecode(enc: String = "UTF-8"): String = URLDecoder.decode(this, enc) + +fun String.toIntOrZero() = toIntOrNull() ?: 0 + +fun String.isContentUri() = startsWith("content://") diff --git a/src/android/app/src/main/java/info/cemu/cemu/zip/Zip.kt b/src/android/app/src/main/java/info/cemu/cemu/utils/Zip.kt similarity index 89% rename from src/android/app/src/main/java/info/cemu/cemu/zip/Zip.kt rename to src/android/app/src/main/java/info/cemu/cemu/utils/Zip.kt index 3ad220f7..93a42353 100644 --- a/src/android/app/src/main/java/info/cemu/cemu/zip/Zip.kt +++ b/src/android/app/src/main/java/info/cemu/cemu/utils/Zip.kt @@ -1,4 +1,4 @@ -package info.cemu.cemu.zip +package info.cemu.cemu.utils import java.io.FileOutputStream import java.io.IOException @@ -7,13 +7,11 @@ import java.nio.file.Paths import java.util.zip.ZipEntry import java.util.zip.ZipInputStream -private const val DEFAULT_BUFFER_SIZE = 8192 - @Throws(IOException::class) fun unzip(stream: InputStream?, targetDir: String) { ZipInputStream(stream).use { zipInputStream -> var zipEntry: ZipEntry - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + val buffer = ByteArray(8192) while ((zipInputStream.nextEntry.also { zipEntry = it }) != null) { extractZipEntry(zipInputStream, zipEntry, buffer, targetDir) zipInputStream.closeEntry() diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index e24e3bbf..d36f3e7e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -267,4 +267,68 @@ <string name="website">Website:</string> <string name="ih264_library_description">Modified ih264 from Android project</string> <string name="more_options">More options</string> + <string name="title_manager">Title manager</string> + <string name="entry_type_base">Base</string> + <string name="entry_type_update">Update</string> + <string name="entry_type_dlc">DLC</string> + <string name="entry_type_save">Save</string> + <string name="entry_type_system">System</string> + <string name="entry_format_folder">Folder</string> + <string name="entry_format_save_folder">Save folder</string> + <string name="entry_format_wuhb">WUHB</string> + <string name="entry_format_wua">WUA</string> + <string name="entry_format_nus">NUS</string> + <string name="entry_format_wud">WUD</string> + <string name="location_mlc">MLC</string> + <string name="location_game_paths">Game paths</string> + <string name="titles_refreshing_notification">Refreshing titles</string> + <string name="title_entry_delete_info">[%1$s] [%2$s] [%3$s]</string> + <string name="title_entry_delete_confirmation">Are you really sure you want to delete the following entry?</string> + <string name="warning">Warning</string> + <string name="title_entry_delete_notification">Deleted title entry</string> + <string name="title_entry_failed_delete_notification">Failed to delete title entry</string> + <string name="title_exists_error_different_type">It seems that there is already a title installed at the target location but it has a different type.\nCurrently installed: \'%1$s\' Installing: \'%2$s\'\nDo you still want to continue with the installation? It will replace the currently installed title.</string> + <string name="title_exists_error_same_version">It seems that the selected title is already installed, do you want to reinstall it?</string> + <string name="title_exists_error_new_version">It seems that a newer version is already installed, do you still want to install the older version?</string> + <string name="ok">OK</string> + <string name="enabled">Enabled</string> + <string name="wua_convert_action_label">Convert to WUA</string> + <string name="wua_convert_current_progress">Current progress: %1$s</string> + <string name="wua_convert_title">Compressing title</string> + <string name="wua_convert_cancel_title">Cancel compressing title</string> + <string name="wua_convert_cancel_text">Do you really want to cancel compressing the title?</string> + <string name="wua_convert_title_not_installed">Not installed</string> + <string name="wua_convert_confirmation_description">The following content will be converted to a compressed Wii U archive file (.wua)</string> + <string name="wua_convert_base_game_title_entry">Base game</string> + <string name="wua_convert_update_title_entry">Update</string> + <string name="wua_convert_dlc_title_entry">DLC</string> + <string name="wua_convert_confirmation_title">Confirmation</string> + <string name="wua_convert_finished">Finished converting</string> + <string name="wua_convert_error">Error while converting</string> + <string name="install_title_progress_title">Installing title</string> + <string name="install_title_parsing_content">Parsing title content…</string> + <string name="install_title_cancel_title">Cancel installing title</string> + <string name="install_title_cancel_text">Do you really want to cancel the installation process?\n\nCanceling the process will delete the applied files.</string> + <string name="install_title_finished">Finished installing</string> + <string name="install_title_error">Error installing</string> + <string name="install_title_invalid_title">Invalid title</string> + <string name="title_manager_search_label">Search titles</string> + <string name="title_manager_filter_types_label">Types</string> + <string name="title_manager_filter_formats_label">Formats</string> + <string name="title_manager_filter_locations_label">Locations</string> + <string name="title_manager_show_info_description">Show more info</string> + <string name="delete">Delete</string> + <string name="delete_title_progress_dialog_title">Deleting</string> + <string name="delete_title_title_info">Deleting %1$s</string> + <string name="title_info_id_label">Title ID</string> + <string name="title_info_type_label">Type</string> + <string name="title_info_version_label">Version</string> + <string name="title_info_region_label">Region</string> + <string name="title_info_format_label">Format</string> + <string name="title_info_location_label">Location</string> + <string name="title_manager_screen_label">Title manager</string> + <string name="title_manager_refresh_titles_description">Refresh titles</string> + <string name="title_manager_filter_titles_description">Filter titles</string> + <string name="title_manager_install_title_description">Install title</string> + <string name="title_dropdown_menu_description">Show dropdown menu for title</string> </resources> \ No newline at end of file