diff --git a/src/android/.gitignore b/src/android/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/src/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/src/android/app/.gitignore b/src/android/app/.gitignore new file mode 100644 index 00000000..5a84e626 --- /dev/null +++ b/src/android/app/.gitignore @@ -0,0 +1,4 @@ +/build +/src/main/assets/hash.txt +*.po +*.pot diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts new file mode 100644 index 00000000..1b591aee --- /dev/null +++ b/src/android/app/build.gradle.kts @@ -0,0 +1,239 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn +import java.io.IOException +import java.security.MessageDigest +import java.util.regex.Pattern +import javax.xml.bind.DatatypeConverter + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinx.gettext) + alias(libs.plugins.aboutlibraries.android) +} + +fun String.runCommand(workingDir: File = File(".")): String? { + try { + val proc = ProcessBuilder(*trim().split("\\s".toRegex()).toTypedArray()) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + assert(proc.waitFor(1, TimeUnit.MINUTES)) + return proc.inputStream.bufferedReader().readText() + } catch (e: IOException) { + e.printStackTrace() + return null + } +} + +fun getGitHash(): String? = "git log --format=%h -1".runCommand()?.trim() + +val versionMajor: Int? = System.getenv("EMULATOR_VERSION_MAJOR")?.toIntOrNull() +val versionMinor: Int? = System.getenv("EMULATOR_VERSION_MINOR")?.toIntOrNull() +versionMajor +fun getVersionName(): String { + if (versionMajor != null && versionMinor != null) + return "$versionMajor.$versionMinor" + return getGitHash() ?: "1.0" +} + +fun getVersionCode(): Int = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 1 + +val cemuDataFilesFolder = "../../../bin" + +android { + namespace = "info.cemu.cemu" + compileSdk = 36 + ndkVersion = "28.2.13676358" + defaultConfig { + applicationId = "info.cemu.cemu" + minSdk = 31 + targetSdk = 36 + versionCode = getVersionCode() + versionName = getVersionName() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + androidResources { + ignoreAssetsPattern = "!*cemu.mo:" + } + + sourceSets.getByName("main") { + assets { + srcDir(cemuDataFilesFolder) + } + } + + packaging { + jniLibs.useLegacyPackaging = true + } + + val keystoreFilePath: String? = System.getenv("ANDROID_STORE_FILE") + + signingConfigs { + if (keystoreFilePath != null) { + create("release") { + storeFile = file(keystoreFilePath) + storePassword = System.getenv("ANDROID_KEY_STORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_STORE_PASSWORD") + } + } + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = if (keystoreFilePath != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } + + compileOptions { + sourceCompatibility(JavaVersion.VERSION_17) + targetCompatibility(JavaVersion.VERSION_17) + } + + externalNativeBuild { + cmake { + version = "3.25.0+" + path = file("../../../CMakeLists.txt") + } + } + + defaultConfig { + externalNativeBuild { + cmake { + arguments( + "-DANDROID_STL=c++_shared", + "-DENABLE_VCPKG=ON", + "-DVCPKG_TARGET_ANDROID=ON", + "-DENABLE_SDL=OFF", + "-DENABLE_WXWIDGETS=OFF", + "-DENABLE_OPENGL=OFF", + "-DENABLE_BLUEZ=OFF", + "-DBUNDLE_SPEEX=ON", + "-DENABLE_DISCORD_RPC=OFF", + "-DENABLE_NSYSHID_LIBUSB=OFF", + "-DENABLE_WAYLAND=OFF", + "-DENABLE_HIDAPI=OFF" + ) + if (versionMajor != null && versionMinor != null) { + arguments.addAll( + arrayOf( + "-DEMULATOR_VERSION_MAJOR=$versionMajor", + "-DEMULATOR_VERSION_MINOR=$versionMinor" + ) + ) + } + abiFilters("arm64-v8a") + } + } + } + + buildFeatures { + buildConfig = true + dataBinding = true + viewBinding = true + compose = true + } + + kotlinOptions { + jvmTarget = "17" + } +} + +abstract class ComputeCemuDataFilesHashTask : DefaultTask() { + private val ignoreFilePatterns = arrayOf( + Pattern.compile(".*cemu\\.mo"), + Pattern.compile(".*Cemu_(?:debug|release)"), + ) + + @get:Input + abstract val cemuDataFolder: Property + + private fun isFileIgnored(file: File): Boolean { + return ignoreFilePatterns.any { pattern -> pattern.matcher(file.path).matches() } + } + + @TaskAction + fun computeCemuDataFilesHash() { + val assetDir = File(project.projectDir, "src/main/assets") + if (!assetDir.exists()) { + assetDir.mkdirs() + } + + val cemuDataFilesDir = File(project.projectDir, cemuDataFolder.get()) + val hashFile = File(assetDir, "hash.txt") + val md = MessageDigest.getInstance("SHA-256") + + if (!cemuDataFilesDir.isDirectory) { + hashFile.writeText("invalid") + return + } + + val fileHashes = cemuDataFilesDir.walkTopDown() + .filter { it.isFile && !isFileIgnored(it) } + .sortedBy { it.path } + .map { + md.reset() + md.update(it.path.toByteArray()) + md.update(it.readBytes()) + md.digest() + } + .toList() + + md.reset() + fileHashes.forEach { md.update(it) } + + hashFile.writeText(DatatypeConverter.printHexBinary(md.digest())) + } +} + +val computeCemuDataFilesHashTask = + tasks.register("computeCemuDataFilesHash") { + cemuDataFolder = cemuDataFilesFolder + } +tasks.preBuild.dependsOn(computeCemuDataFilesHashTask) + +gettext { + potFile.set(File(projectDir, "cemu_kt.pot")) + keywords.set(listOf("tr", "trNoop")) +} + +dependencies { + implementation(libs.aboutlibraries.compose.m3) + implementation(libs.kotlinx.gettext) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + testImplementation(libs.archunit.junit4) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + implementation(libs.okhttp) + implementation(libs.okhttp.coroutines) + implementation(libs.androidx.appcompat) + implementation(libs.google.android.material) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.core.ktx) +} diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro new file mode 100644 index 00000000..e9caf635 --- /dev/null +++ b/src/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-dontobfuscate diff --git a/src/android/app/src/androidTest/java/info/cemu/cemu/tests/PlaceholderInstrumentedTest.kt b/src/android/app/src/androidTest/java/info/cemu/cemu/tests/PlaceholderInstrumentedTest.kt new file mode 100644 index 00000000..fc9361b1 --- /dev/null +++ b/src/android/app/src/androidTest/java/info/cemu/cemu/tests/PlaceholderInstrumentedTest.kt @@ -0,0 +1,14 @@ +package info.cemu.cemu.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaceholderInstrumentedTest { + @Test + fun test() { + assertTrue(true) + } +} diff --git a/src/android/app/src/debug/res/drawable/ic_debug.xml b/src/android/app/src/debug/res/drawable/ic_debug.xml new file mode 100644 index 00000000..af4dba26 --- /dev/null +++ b/src/android/app/src/debug/res/drawable/ic_debug.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..b6fd7c49 --- /dev/null +++ b/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..b6fd7c49 --- /dev/null +++ b/src/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/android/app/src/debug/res/values/strings.xml b/src/android/app/src/debug/res/values/strings.xml new file mode 100644 index 00000000..f37573ce --- /dev/null +++ b/src/android/app/src/debug/res/values/strings.xml @@ -0,0 +1,3 @@ + + Cemu debug + \ No newline at end of file diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c6e714fd --- /dev/null +++ b/src/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/cpp/AndroidAudio.cpp b/src/android/app/src/main/cpp/AndroidAudio.cpp new file mode 100644 index 00000000..5c7040de --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidAudio.cpp @@ -0,0 +1,56 @@ +#include "AndroidAudio.h" + +#include "Cafe/OS/libs/snd_core/ax.h" +#include "audio/IAudioAPI.h" + +#if HAS_CUBEB +#include "audio/CubebAPI.h" +#endif // HAS_CUBEB + +namespace AndroidAudio +{ + + void createAudioDevice(IAudioAPI::AudioAPI audioApi, sint32 channels, sint32 volume, bool isTV) + { + static constexpr int AX_FRAMES_PER_GROUP = 4; + std::unique_lock lock(g_audioMutex); + auto& audioDevice = isTV ? g_tvAudio : g_padAudio; + switch (channels) + { + case 0: + channels = 1; + break; + case 2: + channels = 6; + break; + default: // stereo + channels = 2; + break; + } + switch (audioApi) + { +#if HAS_CUBEB + case IAudioAPI::AudioAPI::Cubeb: + { + audioDevice.reset(); + std::shared_ptr deviceDescriptionPtr = std::make_shared(nullptr, std::string(), std::wstring()); + audioDevice = IAudioAPI::CreateDevice(IAudioAPI::AudioAPI::Cubeb, deviceDescriptionPtr, 48000, channels, snd_core::AX_SAMPLES_PER_3MS_48KHZ * AX_FRAMES_PER_GROUP, 16); + audioDevice->SetVolume(volume); + break; + } +#endif // HAS_CUBEB + default: + cemuLog_log(LogType::Force, "Invalid audio api: {}", audioApi); + break; + } + } + + void setAudioVolume(sint32 volume, bool isTV) + { + std::shared_lock lock(g_audioMutex); + auto& audioDevice = isTV ? g_tvAudio : g_padAudio; + if (audioDevice) + audioDevice->SetVolume(volume); + } + +}; // namespace AndroidAudio diff --git a/src/android/app/src/main/cpp/AndroidAudio.h b/src/android/app/src/main/cpp/AndroidAudio.h new file mode 100644 index 00000000..ef89e9ed --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidAudio.h @@ -0,0 +1,9 @@ +#pragma once + +#include "audio/IAudioAPI.h" + +namespace AndroidAudio +{ + void createAudioDevice(IAudioAPI::AudioAPI audioApi, sint32 channels, sint32 volume, bool isTV = true); + void setAudioVolume(sint32 volume, bool isTV = true); +}; // namespace AndroidAudio diff --git a/src/android/app/src/main/cpp/AndroidEmulatedController.cpp b/src/android/app/src/main/cpp/AndroidEmulatedController.cpp new file mode 100644 index 00000000..1d69a19c --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidEmulatedController.cpp @@ -0,0 +1,3 @@ +#include "AndroidEmulatedController.h" + +std::array, InputManager::kMaxController> AndroidEmulatedController::s_emulatedControllers; diff --git a/src/android/app/src/main/cpp/AndroidEmulatedController.h b/src/android/app/src/main/cpp/AndroidEmulatedController.h new file mode 100644 index 00000000..a1d1a183 --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidEmulatedController.h @@ -0,0 +1,123 @@ +#pragma once + +#include "input/InputManager.h" +#include "input/api/Controller.h" +#include "input/emulated/ClassicController.h" +#include "input/emulated/ProController.h" +#include "input/emulated/WiimoteController.h" + +class AndroidEmulatedController { + private: + size_t m_index; + static std::array, InputManager::kMaxController> s_emulatedControllers; + EmulatedControllerPtr m_emulatedController; + AndroidEmulatedController(size_t index) + : m_index(index) + { + m_emulatedController = InputManager::instance().get_controller(m_index); + } + + public: + static AndroidEmulatedController& getAndroidEmulatedController(size_t index) + { + auto& controller = s_emulatedControllers.at(index); + if (!controller) + controller = std::unique_ptr(new AndroidEmulatedController(index)); + return *controller; + } + void setButtonValue(uint64 mappingId, bool value) + { + if (!m_emulatedController) + return; + m_emulatedController->setButtonValue(mappingId, value); + } + void setAxisValue(uint64 mappingId, float value) + { + if (!m_emulatedController) + return; + m_emulatedController->setAxisValue(mappingId, value); + } + void setType(EmulatedController::Type type) + { + if (m_emulatedController && m_emulatedController->type() == type) + return; + m_emulatedController = InputManager::instance().set_controller(m_index, type); + InputManager::instance().save(m_index); + } + void setMapping(uint64 mappingId, ControllerPtr controller, uint64 buttonId) + { + if (m_emulatedController && controller) + { + const auto& controllers = m_emulatedController->get_controllers(); + auto controllerIt = std::find_if(controllers.begin(), controllers.end(), [&](const ControllerPtr& c) { return c->api() == controller->api() && c->uuid() == controller->uuid(); }); + if (controllerIt == controllers.end()) + m_emulatedController->add_controller(controller); + else + controller = *controllerIt; + m_emulatedController->set_mapping(mappingId, controller, buttonId); + InputManager::instance().save(m_index); + } + } + std::optional getMapping(uint64 mapping) const + { + if (!m_emulatedController) + return {}; + auto controller = m_emulatedController->get_mapping_controller(mapping); + if (!controller) + return {}; + auto mappingName = m_emulatedController->get_mapping_name(mapping); + return fmt::format("{}: {}", controller->display_name(), mappingName); + } + std::map getMappings() const + { + if (!m_emulatedController) + return {}; + std::map mappings; + auto type = m_emulatedController->type(); + uint64 mapping = 0; + uint64 maxMapping = 0; + if (type == EmulatedController::Type::VPAD) + { + mapping = VPADController::ButtonId::kButtonId_A; + maxMapping = VPADController::ButtonId::kButtonId_Max; + } + if (type == EmulatedController::Type::Pro) + { + mapping = ProController::ButtonId::kButtonId_A; + maxMapping = ProController::ButtonId::kButtonId_Max; + } + if (type == EmulatedController::Type::Classic) + { + mapping = ClassicController::ButtonId::kButtonId_A; + maxMapping = ClassicController::ButtonId::kButtonId_Max; + } + if (type == EmulatedController::Type::Wiimote) + { + mapping = WiimoteController::ButtonId::kButtonId_A; + maxMapping = WiimoteController::ButtonId::kButtonId_Max; + } + for (; mapping < maxMapping; mapping++) + { + auto mappingName = getMapping(mapping); + if (mappingName.has_value()) + mappings[mapping] = mappingName.value(); + } + return mappings; + } + void setDisabled() + { + InputManager::instance().delete_controller(m_index, true); + m_emulatedController.reset(); + } + EmulatedControllerPtr getEmulatedController() + { + return m_emulatedController; + } + + void clearMapping(uint64 mapping) + { + if (!m_emulatedController) + return; + m_emulatedController->delete_mapping(mapping); + } +}; diff --git a/src/android/app/src/main/cpp/AndroidFilesystemCallbacks.h b/src/android/app/src/main/cpp/AndroidFilesystemCallbacks.h new file mode 100644 index 00000000..5ef29109 --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidFilesystemCallbacks.h @@ -0,0 +1,82 @@ +#pragma once + +#include "JNIUtils.h" +#include "Common/android/FilesystemAndroid.h" + +class AndroidFilesystemCallbacks : public FilesystemAndroid::FilesystemCallbacks { + jmethodID m_openContentUriMid; + jmethodID m_listFilesMid; + jmethodID m_isDirectoryMid; + jmethodID m_isFileMid; + jmethodID m_existsMid; + JNIUtils::Scopedjclass m_fileUtilClass; + + bool CallBooleanFunction(const std::filesystem::path& uri, jmethodID methodId) + { + bool result = false; + JNIUtils::fiberSafeJNICall([&](JNIEnv* env) { + jstring uriString = JNIUtils::toJString(env, uri); + result = env->CallStaticBooleanMethod(*m_fileUtilClass, methodId, uriString); + env->DeleteLocalRef(uriString); + }); + return result; + } + + public: + AndroidFilesystemCallbacks() + { + JNIUtils::ScopedJNIENV env; + m_fileUtilClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeFiles"); + m_openContentUriMid = env->GetStaticMethodID(*m_fileUtilClass, "openContentUri", "(Ljava/lang/String;)I"); + m_listFilesMid = env->GetStaticMethodID(*m_fileUtilClass, "listFiles", "(Ljava/lang/String;)[Ljava/lang/String;"); + m_isDirectoryMid = env->GetStaticMethodID(*m_fileUtilClass, "isDirectory", "(Ljava/lang/String;)Z"); + m_isFileMid = env->GetStaticMethodID(*m_fileUtilClass, "isFile", "(Ljava/lang/String;)Z"); + m_existsMid = env->GetStaticMethodID(*m_fileUtilClass, "exists", "(Ljava/lang/String;)Z"); + } + + int OpenContentUri(const std::filesystem::path& uri) override + { + int fd = -1; + JNIUtils::fiberSafeJNICall([&](JNIEnv* env) { + jstring uriString = JNIUtils::toJString(env, uri); + fd = env->CallStaticIntMethod(*m_fileUtilClass, m_openContentUriMid, uriString); + env->DeleteLocalRef(uriString); + }); + return fd; + } + + std::vector ListFiles(const std::filesystem::path& uri) override + { + std::vector paths; + JNIUtils::fiberSafeJNICall([&](JNIEnv* env) { + 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); + paths.reserve(arrayLength); + for (jsize i = 0; i < arrayLength; i++) + { + jstring pathStr = static_cast(env->GetObjectArrayElement(pathsObjArray, i)); + paths.push_back(JNIUtils::toString(env, pathStr)); + env->DeleteLocalRef(pathStr); + } + env->DeleteLocalRef(pathsObjArray); + }); + return paths; + } + + bool IsDirectory(const std::filesystem::path& uri) override + { + return CallBooleanFunction(uri, m_isDirectoryMid); + } + + bool IsFile(const std::filesystem::path& uri) override + { + return CallBooleanFunction(uri, m_isFileMid); + } + + bool Exists(const std::filesystem::path& uri) override + { + return CallBooleanFunction(uri, m_existsMid); + } +}; \ No newline at end of file diff --git a/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h new file mode 100644 index 00000000..71ac63ca --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h @@ -0,0 +1,71 @@ +#pragma once + +#include "GameTitleLoader.h" +#include "JNIUtils.h" +#include +// TODO: Refactor this: +class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback +{ + jmethodID m_onGameTitleLoadedMID; + JNIUtils::Scopedjobject m_gameTitleLoadedCallbackObj; + jmethodID m_gameConstructorMID; + JNIUtils::Scopedjclass m_gamejclass{"info/cemu/cemu/nativeinterface/NativeGameTitles$Game"}; + jmethodID m_createBitmapMID; + JNIUtils::Scopedjclass m_bitmapClass{"android/graphics/Bitmap"}; + JNIUtils::Scopedjobject m_bitmapFormat; + + public: + AndroidGameTitleLoadedCallback(jmethodID onGameTitleLoadedMID, jobject gameTitleLoadedCallbackObj) + : m_onGameTitleLoadedMID(onGameTitleLoadedMID), + m_gameTitleLoadedCallbackObj(gameTitleLoadedCallbackObj) + { + JNIUtils::ScopedJNIENV env; + 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;"); + } + + void onTitleLoaded(const Game& game, const std::shared_ptr& icon) override + { + static JNIUtils::ScopedJNIENV env; + 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()) + { + lastPlayedYear = static_cast(game.lastPlayed->year()); + lastPlayedMonth = static_cast(game.lastPlayed->month()); + lastPlayedDay = static_cast(game.lastPlayed->day()); + } + if (icon) + { + jintArray jIconData = env->NewIntArray(icon->m_width * icon->m_height); + env->SetIntArrayRegion(jIconData, 0, icon->m_width * icon->m_height, icon->m_colors); + bitmap = env->CallStaticObjectMethod(*m_bitmapClass, m_createBitmapMID, jIconData, icon->m_width, icon->m_height, *m_bitmapFormat); + env->DeleteLocalRef(jIconData); + } + jobject gamejobject = env->NewObject( + *m_gamejclass, + m_gameConstructorMID, + game.titleId, + path, + name, + game.version, + game.dlc, + static_cast(game.region), + lastPlayedYear, + lastPlayedMonth, + lastPlayedDay, + game.minutesPlayed, + game.isFavorite, + bitmap); + env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameTitleLoadedMID, gamejobject); + env->DeleteLocalRef(gamejobject); + if (bitmap != nullptr) + env->DeleteLocalRef(bitmap); + if (path != nullptr) + env->DeleteLocalRef(path); + env->DeleteLocalRef(name); + } +}; diff --git a/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp new file mode 100644 index 00000000..270248d3 --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.cpp @@ -0,0 +1,26 @@ +#include "AndroidSwkbdCallbacks.h" +#include "JNIUtils.h" + +AndroidSwkbdCallbacks::AndroidSwkbdCallbacks() +{ + JNIUtils::ScopedJNIENV env; + m_emulationActivityClass = JNIUtils::Scopedjclass("info/cemu/cemu/emulation/EmulationActivity"); + m_showSoftwareKeyboardMethodID = env->GetStaticMethodID(*m_emulationActivityClass, "showEmulationTextInput", "(Ljava/lang/String;I)V"); + m_hideSoftwareKeyboardMethodID = env->GetStaticMethodID(*m_emulationActivityClass, "hideEmulationTextInput", "()V"); +} + +void AndroidSwkbdCallbacks::showSoftwareKeyboard(const std::string& initialText, sint32 maxLength) +{ + JNIUtils::fiberSafeJNICall([&](JNIEnv* env) { + jstring j_initialText = JNIUtils::toJString(env, initialText); + JNIUtils::ScopedJNIENV()->CallStaticVoidMethod(*m_emulationActivityClass, m_showSoftwareKeyboardMethodID, j_initialText, maxLength); + env->DeleteLocalRef(j_initialText); + }); +} + +void AndroidSwkbdCallbacks::hideSoftwareKeyboard() +{ + JNIUtils::fiberSafeJNICall([&](JNIEnv* env) { + env->CallStaticVoidMethod(*m_emulationActivityClass, m_hideSoftwareKeyboardMethodID); + }); +} diff --git a/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.h b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.h new file mode 100644 index 00000000..845223e3 --- /dev/null +++ b/src/android/app/src/main/cpp/AndroidSwkbdCallbacks.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Cafe/OS/libs/swkbd/swkbd.h" +#include "JNIUtils.h" +class AndroidSwkbdCallbacks : public swkbd::swkbdCallbacks +{ + JNIUtils::Scopedjclass m_emulationActivityClass; + jmethodID m_showSoftwareKeyboardMethodID; + jmethodID m_hideSoftwareKeyboardMethodID; + + public: + AndroidSwkbdCallbacks(); + void showSoftwareKeyboard(const std::string& initialText, sint32 maxLength) override; + void hideSoftwareKeyboard() override; +}; diff --git a/src/android/app/src/main/cpp/CMakeLists.txt b/src/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..a0144ea2 --- /dev/null +++ b/src/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,33 @@ +add_library(CemuAndroid SHARED + AndroidAudio.cpp + AndroidEmulatedController.cpp + AndroidSwkbdCallbacks.cpp + CompressTitleCallbacks.cpp + GameTitleLoader.cpp + Image.cpp + JNIUtils.cpp + NativeActiveSettings.cpp + NativeAccount.cpp + NativeEmulation.cpp + NativeGameTitles.cpp + NativeGraphicPacks.cpp + NativeInput.cpp + NativeLib.cpp + NativeLocalization.cpp + NativeLogging.cpp + NativeSettings.cpp + NativeSwkbd.cpp + WuaConverter.cpp +) + +target_link_libraries(CemuAndroid PRIVATE + -landroid + CemuCommon + CemuAudio + CemuComponents + CemuCafe + CemuBin + CemuGui + ZArchive::zarchive + stb +) 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/GameTitleLoader.cpp b/src/android/app/src/main/cpp/GameTitleLoader.cpp new file mode 100644 index 00000000..7998a14d --- /dev/null +++ b/src/android/app/src/main/cpp/GameTitleLoader.cpp @@ -0,0 +1,163 @@ +#include "GameTitleLoader.h" + +std::optional getFirstTitleInfoByTitleId(TitleId titleId) +{ + TitleInfo titleInfo; + if (CafeTitleList::GetFirstByTitleId(titleId, titleInfo)) + return titleInfo; + return {}; +} + +GameTitleLoader::GameTitleLoader() +{ + m_loaderThread = std::thread(&GameTitleLoader::loadGameTitles, this); +} + +void GameTitleLoader::queueTitle(TitleId titleId) +{ + { + std::lock_guard lock(m_threadMutex); + m_titlesToLoad.emplace_front(titleId); + } + m_condVar.notify_one(); +} + +void GameTitleLoader::setOnTitleLoaded(const std::shared_ptr& gameTitleLoadedCallback) +{ + { + std::lock_guard lock(m_threadMutex); + m_gameTitleLoadedCallback = gameTitleLoadedCallback; + } + m_condVar.notify_one(); +} + +void GameTitleLoader::reloadGameTitles() +{ + if (m_callbackIdTitleList.has_value()) + { + CafeTitleList::UnregisterCallback(m_callbackIdTitleList.value()); + } + m_gameInfos.clear(); + CafeTitleList::ClearScanPaths(); + for (auto&& gamePath : GetConfig().game_paths) + CafeTitleList::AddScanPath(gamePath); + CafeTitleList::Refresh(); + m_callbackIdTitleList = CafeTitleList::RegisterCallback([](CafeTitleListCallbackEvent* evt, void* ctx) { static_cast(ctx)->HandleTitleListCallback(evt); }, this); +} + +GameTitleLoader::~GameTitleLoader() +{ + m_continueLoading = false; + m_condVar.notify_one(); + m_loaderThread.join(); + if (m_callbackIdTitleList.has_value()) + CafeTitleList::UnregisterCallback(m_callbackIdTitleList.value()); +} + +void GameTitleLoader::titleRefresh(TitleId titleId) +{ + using namespace std::chrono; + GameInfo2 gameInfo = CafeTitleList::GetGameInfo(titleId); + if (!gameInfo.IsValid()) + { + return; + } + TitleId baseTitleId = gameInfo.GetBaseTitleId(); + bool isNewEntry = false; + if (auto gameInfoIt = m_gameInfos.find(baseTitleId); gameInfoIt == m_gameInfos.end()) + { + isNewEntry = true; + m_gameInfos[baseTitleId] = Game(); + } + + Game& game = m_gameInfos[baseTitleId]; + std::optional titleInfo = getFirstTitleInfoByTitleId(titleId); + game.titleId = baseTitleId; + if (titleInfo.has_value()) + game.path = titleInfo->GetPath(); + game.isFavorite = GetConfig().IsGameListFavorite(baseTitleId); + game.name = getNameByTitleId(baseTitleId, titleInfo); + game.version = gameInfo.GetVersion(); + game.region = gameInfo.GetRegion(); + game.dlc = gameInfo.GetAOCVersion(); + std::shared_ptr icon = loadIcon(baseTitleId, titleInfo); + if (!isNewEntry) + { + // TOOD: update? + return; + } + iosu::pdm::GameListStat playTimeStat{}; + if (iosu::pdm::GetStatForGamelist(baseTitleId, playTimeStat)) + { + game.minutesPlayed = playTimeStat.numMinutesPlayed; + if (playTimeStat.last_played.year != 0) + { + game.lastPlayed = year_month_day(year(playTimeStat.last_played.year), month(playTimeStat.last_played.month + 1), day(playTimeStat.last_played.day)); + } + } + if (m_gameTitleLoadedCallback) + m_gameTitleLoadedCallback->onTitleLoaded(game, icon); +} + +void GameTitleLoader::loadGameTitles() +{ + while (m_continueLoading) + { + TitleId titleId; + { + std::unique_lock lock(m_threadMutex); + m_condVar.wait(lock, [this] { return (!m_titlesToLoad.empty()) || !m_continueLoading; }); + if (!m_continueLoading) + return; + titleId = m_titlesToLoad.front(); + m_titlesToLoad.pop_front(); + } + titleRefresh(titleId); + } +} +std::string GameTitleLoader::getNameByTitleId(TitleId titleId, const std::optional& titleInfo) +{ + auto it = m_name_cache.find(titleId); + if (it != m_name_cache.end()) + return it->second; + if (!titleInfo.has_value()) + return "Unknown title"; + std::string name; + if (!GetConfig().GetGameListCustomName(titleId, name)) + name = titleInfo.value().GetMetaTitleName(); + m_name_cache.emplace(titleId, name); + return name; +} + +std::shared_ptr GameTitleLoader::loadIcon(TitleId titleId, const std::optional& titleInfo) +{ + if (auto iconIt = m_iconCache.find(titleId); iconIt != m_iconCache.end()) + return iconIt->second; + std::string tempMountPath = TitleInfo::GetUniqueTempMountingPath(); + if (!titleInfo.has_value()) + return {}; + auto titleInfoValue = titleInfo.value(); + if (!titleInfoValue.Mount(tempMountPath, "", FSC_PRIORITY_BASE)) + return {}; + auto tgaData = fsc_extractFile((tempMountPath + "/meta/iconTex.tga").c_str()); + if (!tgaData || tgaData->size() <= 16) + { + cemuLog_log(LogType::Force, "Failed to load icon for title {:016x}", titleId); + titleInfoValue.Unmount(tempMountPath); + return {}; + } + auto image = std::make_shared(tgaData.value()); + titleInfoValue.Unmount(tempMountPath); + if (!image->isOk()) + return {}; + m_iconCache.emplace(titleId, image); + return image; +} + +void GameTitleLoader::HandleTitleListCallback(CafeTitleListCallbackEvent* evt) +{ + if (evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED || evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED) + { + queueTitle(evt->titleInfo->GetAppTitleId()); + } +} diff --git a/src/android/app/src/main/cpp/GameTitleLoader.h b/src/android/app/src/main/cpp/GameTitleLoader.h new file mode 100644 index 00000000..677dcbc2 --- /dev/null +++ b/src/android/app/src/main/cpp/GameTitleLoader.h @@ -0,0 +1,53 @@ +#pragma once + +#include "Cafe/IOSU/PDM/iosu_pdm.h" +#include "Cafe/TitleList/TitleId.h" +#include "Cafe/TitleList/TitleList.h" +#include "Image.h" + +struct Game +{ + std::string name; + std::optional path; + bool isFavorite; + uint16 version; + uint16 dlc; + TitleId titleId; + std::optional lastPlayed; + uint32 minutesPlayed; + CafeConsoleRegion region; +}; + +class GameTitleLoadedCallback +{ + public: + virtual void onTitleLoaded(const Game& game, const std::shared_ptr& icon) = 0; +}; + +class GameTitleLoader +{ + std::mutex m_threadMutex; + std::condition_variable m_condVar; + std::thread m_loaderThread; + std::atomic_bool m_continueLoading = true; + std::deque m_titlesToLoad; + std::optional m_callbackIdTitleList; + std::map m_gameInfos; + std::map> m_iconCache; + std::map m_name_cache; + std::shared_ptr m_gameTitleLoadedCallback = nullptr; + + public: + GameTitleLoader(); + void queueTitle(TitleId titleId); + void setOnTitleLoaded(const std::shared_ptr& gameTitleLoadedCallback); + void reloadGameTitles(); + ~GameTitleLoader(); + void titleRefresh(TitleId titleId); + + private: + void loadGameTitles(); + std::string getNameByTitleId(TitleId titleId, const std::optional& titleInfo); + std::shared_ptr loadIcon(TitleId titleId, const std::optional& titleInfo); + void HandleTitleListCallback(CafeTitleListCallbackEvent* evt); +}; diff --git a/src/android/app/src/main/cpp/Image.cpp b/src/android/app/src/main/cpp/Image.cpp new file mode 100644 index 00000000..2269dcb4 --- /dev/null +++ b/src/android/app/src/main/cpp/Image.cpp @@ -0,0 +1,39 @@ +#include "Image.h" + +#define STB_IMAGE_IMPLEMENTATION +#define STBI_ONLY_TGA + +#include + +Image::Image(Image&& image) +{ + this->m_colors = image.m_colors; + this->m_width = image.m_width; + this->m_height = image.m_height; + this->m_channels = image.m_channels; + image.m_colors = nullptr; +} + +Image::Image(const std::vector& imageBytes) +{ + stbi_uc* stbImage = stbi_load_from_memory(imageBytes.data(), imageBytes.size(), &m_width, &m_height, &m_channels, STBI_rgb_alpha); + if (!stbImage) + return; + for (size_t i = 0; i < m_width * m_height * 4; i += 4) + { + // RGBA -> BGRA + std::swap(stbImage[i + 0], stbImage[i + 2]); + } + m_colors = reinterpret_cast(stbImage); +} + +bool Image::isOk() const +{ + return m_colors != nullptr; +} + +Image::~Image() +{ + if (m_colors) + stbi_image_free(m_colors); +} diff --git a/src/android/app/src/main/cpp/Image.h b/src/android/app/src/main/cpp/Image.h new file mode 100644 index 00000000..aedd550f --- /dev/null +++ b/src/android/app/src/main/cpp/Image.h @@ -0,0 +1,17 @@ +#pragma once + +struct Image +{ + sint32* m_colors = nullptr; + int m_width = 0; + int m_height = 0; + int m_channels = 0; + + Image(Image&& image); + + Image(const std::vector& imageBytes); + + bool isOk() const; + + ~Image(); +}; diff --git a/src/android/app/src/main/cpp/JNIUtils.cpp b/src/android/app/src/main/cpp/JNIUtils.cpp new file mode 100644 index 00000000..c3019c01 --- /dev/null +++ b/src/android/app/src/main/cpp/JNIUtils.cpp @@ -0,0 +1,89 @@ +#include "JNIUtils.h" + +namespace JNIUtils +{ + JavaVM* g_jvm = nullptr; +}; + +jobject JNIUtils::createJavaStringArrayList(JNIEnv* env, const std::vector& strings) +{ + jclass clsArrayList = env->FindClass("java/util/ArrayList"); + jmethodID arrayListConstructor = env->GetMethodID(clsArrayList, "", "()V"); + jobject arrayListObject = env->NewObject(clsArrayList, arrayListConstructor); + jmethodID addMethod = env->GetMethodID(clsArrayList, "add", "(Ljava/lang/Object;)Z"); + env->DeleteLocalRef(clsArrayList); + + for (const auto& string : strings) + { + jstring element = env->NewStringUTF(string.c_str()); + env->CallBooleanMethod(arrayListObject, addMethod, element); + env->DeleteLocalRef(element); + } + return arrayListObject; +} + +JNIUtils::Scopedjobject JNIUtils::getEnumValue(JNIEnv* env, const std::string& enumClassName, const std::string& enumName) +{ + jclass enumClass = env->FindClass(enumClassName.c_str()); + jfieldID fieldID = env->GetStaticFieldID(enumClass, enumName.c_str(), ("L" + enumClassName + ";").c_str()); + jobject enumValue = env->GetStaticObjectField(enumClass, fieldID); + env->DeleteLocalRef(enumClass); + Scopedjobject enumObj = Scopedjobject(enumValue); + env->DeleteLocalRef(enumValue); + return enumObj; +} + +jobject JNIUtils::createArrayList(JNIEnv* env, const std::vector& objects) +{ + static Scopedjclass listClass = Scopedjclass("java/util/ArrayList"); + static jmethodID listConstructor = env->GetMethodID(*listClass, "", "()V"); + static jmethodID listAdd = env->GetMethodID(*listClass, "add", "(Ljava/lang/Object;)Z"); + + jobject arrayList = env->NewObject(*listClass, listConstructor); + for (auto&& obj : objects) + env->CallBooleanMethod(arrayList, listAdd, obj); + return arrayList; +} + +jobject JNIUtils::createJavaLongArrayList(JNIEnv* env, const std::vector& values) +{ + jclass longClass = env->FindClass("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, value)); + env->DeleteLocalRef(longClass); + return JNIUtils::createArrayList(env, valuesJava); +} + +void JNIUtils::handleNativeException(JNIEnv* env, const std::function& fn) +{ + try + { + fn(); + } catch (const std::exception& exception) + { + jclass exceptionClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeException"); + env->ThrowNew(exceptionClass, exception.what()); + } catch (...) + { + jclass exceptionClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeException"); + env->ThrowNew(exceptionClass, "Unknown native exception"); + } +} + +jobject JNIUtils::createJavaStringArrayList(JNIEnv* env, const std::vector& strings) +{ + jclass arrayListClass = env->FindClass("java/util/ArrayList"); + jmethodID arrayListConstructor = env->GetMethodID(arrayListClass, "", "()V"); + jobject arrayListObject = env->NewObject(arrayListClass, arrayListConstructor); + jmethodID addMethod = env->GetMethodID(arrayListClass, "add", "(Ljava/lang/Object;)Z"); + for (const auto& string : strings) + { + jstring element = env->NewString((jchar*)string.c_str(), string.length()); + env->CallBooleanMethod(arrayListObject, addMethod, element); + env->DeleteLocalRef(element); + } + return arrayListObject; +} diff --git a/src/android/app/src/main/cpp/JNIUtils.h b/src/android/app/src/main/cpp/JNIUtils.h new file mode 100644 index 00000000..9ee4d7fa --- /dev/null +++ b/src/android/app/src/main/cpp/JNIUtils.h @@ -0,0 +1,217 @@ +#pragma once + +#include +#include + +namespace JNIUtils +{ + extern JavaVM* g_jvm; + + 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()); + } + + inline jstring toJString(JNIEnv* env, std::string_view str) + { + return toJString(env, std::string(str)); + } + + inline jstring toJString(JNIEnv* env, std::wstring_view str) + { + return toJString(env, boost::nowide::narrow(str)); + } + + jobject createJavaStringArrayList(JNIEnv* env, const std::vector& stringList); + + jobject createJavaStringArrayList(JNIEnv* env, const std::vector& stringList); + + void handleNativeException(JNIEnv* env, const std::function& fn); + + class ScopedJNIENV + { + public: + ScopedJNIENV() + { + jint result = g_jvm->GetEnv((void**)&m_env, JNI_VERSION_1_6); + + 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*() + { + return m_env; + } + + JNIEnv* operator->() + { + return m_env; + } + + operator JNIEnv*() const + { + return m_env; + } + + ~ScopedJNIENV() + { + if (m_threadWasAttached) + g_jvm->DetachCurrentThread(); + } + + private: + JNIEnv* m_env = nullptr; + bool m_threadWasAttached = false; + }; + + class Scopedjobject + { + public: + Scopedjobject() = default; + + Scopedjobject(Scopedjobject&& other) noexcept + { + this->m_jobject = other.m_jobject; + other.m_jobject = nullptr; + } + void deleteRef() + { + if (m_jobject) + { + ScopedJNIENV()->DeleteGlobalRef(m_jobject); + m_jobject = nullptr; + } + } + Scopedjobject& operator=(Scopedjobject&& other) noexcept + { + if (this != &other) + { + deleteRef(); + m_jobject = other.m_jobject; + other.m_jobject = nullptr; + } + return *this; + } + const jobject& operator*() const + { + return m_jobject; + } + + explicit Scopedjobject(jobject obj) + { + if (obj) + m_jobject = ScopedJNIENV()->NewGlobalRef(obj); + } + + ~Scopedjobject() + { + deleteRef(); + } + + bool isValid() const + { + return m_jobject; + } + + private: + jobject m_jobject = nullptr; + }; + + class Scopedjclass + { + public: + Scopedjclass() = default; + + Scopedjclass(Scopedjclass&& other) noexcept + { + this->m_jclass = other.m_jclass; + other.m_jclass = nullptr; + } + + explicit Scopedjclass(jclass javaClass) + { + if (javaClass) + m_jclass = static_cast(ScopedJNIENV()->NewGlobalRef(javaClass)); + } + + Scopedjclass& operator=(Scopedjclass&& other) noexcept + { + if (this != &other) + { + if (m_jclass) + ScopedJNIENV()->DeleteGlobalRef(m_jclass); + m_jclass = other.m_jclass; + other.m_jclass = nullptr; + } + return *this; + } + + explicit Scopedjclass(const std::string& className) + { + ScopedJNIENV scopedEnv; + jclass tempObj = scopedEnv->FindClass(className.c_str()); + m_jclass = static_cast(scopedEnv->NewGlobalRef(tempObj)); + scopedEnv->DeleteLocalRef(tempObj); + } + + ~Scopedjclass() + { + if (m_jclass) + ScopedJNIENV()->DeleteGlobalRef(m_jclass); + } + + bool isValid() const + { + return m_jclass != nullptr; + } + + const jclass& operator*() const + { + return m_jclass; + } + + private: + jclass m_jclass = nullptr; + }; + + 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; + } + + inline void fiberSafeJNICall(const std::function& func) + { + std::thread([&]() { + ScopedJNIENV env; + func(*env); + }).join(); + } +} // namespace JNIUtils diff --git a/src/android/app/src/main/cpp/NativeAccount.cpp b/src/android/app/src/main/cpp/NativeAccount.cpp new file mode 100644 index 00000000..c2d52251 --- /dev/null +++ b/src/android/app/src/main/cpp/NativeAccount.cpp @@ -0,0 +1,256 @@ +#include +#include "WindowSystem.h" +#include "JNIUtils.h" +#include "AndroidAudio.h" +#include "AndroidEmulatedController.h" +#include "AndroidFilesystemCallbacks.h" +#include "Cafe/HW/Latte/Core/LatteOverlay.h" +#include "Cafe/HW/Latte/Renderer/Vulkan/VulkanAPI.h" +#include "Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.h" +#include "Cafe/CafeSystem.h" +#include "GameTitleLoader.h" +#include "input/ControllerFactory.h" +#include "input/InputManager.h" +#include "input/api/Android/AndroidController.h" +#include "input/api/Android/AndroidControllerProvider.h" +#include "config/ActiveSettings.h" +#include "Cemu/ncrypto/ncrypto.h" + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_createAccount(JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id, jstring mii_name) +{ + uint32 persistentId = static_cast(persistent_id); + std::string miiName = JNIUtils::toString(env, mii_name); + Account account(persistentId, boost::nowide::widen(miiName)); + account.Save(); + Account::RefreshAccounts(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_deleteAccount([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id) +{ + uint32 persistentId = static_cast(persistent_id); + const auto& account = Account::GetAccount(persistentId); + + if (account.GetPersistentId() != persistentId) + { + return; + } + + const fs::path path = account.GetFileName(); + + try + { + fs::remove_all(path.parent_path()); + Account::RefreshAccounts(); + } catch (const std::exception& ex) + { + cemuLog_log(LogType::Force, "Failed to delete account {} {}", path.c_str(), ex.what()); + } +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_saveAccount(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject account_java) +{ + using namespace std::chrono; + + jclass accountClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeAccount$Account"); + auto getAccountField = [&](const char* fieldName, const char* sig, auto getFieldFn) -> auto { + auto getField = std::bind(getFieldFn, env, account_java, std::placeholders::_1); + return getField(env->GetFieldID(accountClass, fieldName, sig)); + }; + uint32 persistentId = getAccountField("persistentId", "I", &JNIEnv::GetIntField); + auto account = Account::GetAccount(persistentId); + + if (account.GetPersistentId() != persistentId) + { + return; + } + + jstring miiNameJava = static_cast(getAccountField("miiName", "Ljava/lang/String;", &JNIEnv::GetObjectField)); + account.SetMiiName(boost::nowide::widen(JNIUtils::toString(env, miiNameJava))); + account.SetCountry(getAccountField("country", "I", &JNIEnv::GetIntField)); + account.SetGender(getAccountField("gender", "B", &JNIEnv::GetByteField)); + jstring emailJava = static_cast(getAccountField("email", "Ljava/lang/String;", &JNIEnv::GetObjectField)); + account.SetEmail(JNIUtils::toString(env, emailJava)); + auto birthdayMillis = milliseconds(getAccountField("birthday", "J", &JNIEnv::GetLongField)); + system_clock::time_point birthdayTimePoint(birthdayMillis); + year_month_day birthdayYMD(floor(time_point(birthdayTimePoint))); + if (birthdayYMD.ok()) + { + account.SetBirthYear(static_cast(birthdayYMD.year())); + account.SetBirthMonth(static_cast(birthdayYMD.month())); + account.SetBirthDay(static_cast(birthdayYMD.day())); + } + account.Save(); + + Account::RefreshAccounts(); +} + +jlong toUnixTimestampMillis(std::chrono::year_month_day ymd) +{ + using namespace std::chrono; + if (!ymd.ok()) + return 0; + + auto millis = duration_cast(system_clock::time_point(sys_days(ymd)).time_since_epoch()); + return millis.count(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobjectArray JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_getAccounts(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + using namespace std::chrono; + jclass accountClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeAccount$Account"); + jmethodID accountCtrId = env->GetMethodID(accountClass, "", "(ILjava/lang/String;JBLjava/lang/String;IZ)V"); + + const auto& accounts = Account::GetAccounts(); + jsize accountsCount = static_cast(accounts.size()); + auto accountsJArray = env->NewObjectArray(accountsCount, accountClass, nullptr); + + for (jint i = 0; i < accounts.size(); i++) + { + const auto& account = accounts[i]; + jint persistentId = static_cast(account.GetPersistentId()); + jstring miiName = JNIUtils::toJString(env, account.GetMiiName()); + jlong birthday = toUnixTimestampMillis(year_month_day(year(account.GetBirthYear()), month(account.GetBirthMonth()), day(account.GetBirthDay()))); + jbyte gender = static_cast(account.GetGender()); + jstring email = JNIUtils::toJString(env, account.GetEmail()); + jint country = static_cast(account.GetCountry()); + jboolean isValid = account.IsValidOnlineAccount(); + + jobject accountJObj = env->NewObject( + accountClass, + accountCtrId, + persistentId, + miiName, + birthday, + gender, + email, + country, + isValid); + env->SetObjectArrayElement(accountsJArray, i, accountJObj); + } + + return accountsJArray; +} + +extern "C" [[maybe_unused]] JNIEXPORT jobjectArray JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_getAccountCountries(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + jclass countryClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeAccount$AccountCountry"); + jmethodID countryCtrId = env->GetMethodID(countryClass, "", "(ILjava/lang/String;)V"); + + struct Country + { + jint index; + const char* name; + }; + + std::vector countries; + for (int i = 0; i < NCrypto::GetCountryCount(); ++i) + { + const auto countryName = NCrypto::GetCountryAsString(i); + if (countryName && (i == 0 || !boost::equals(countryName, "NN"))) + { + countries.push_back({.index = i, .name = countryName}); + } + } + + jobjectArray countriesJava = env->NewObjectArray(countries.size(), countryClass, nullptr); + for (int i = 0; i < countries.size(); ++i) + { + const auto& country = countries[i]; + jobject countryJava = env->NewObject(countryClass, + countryCtrId, + country.index, + env->NewStringUTF(country.name)); + env->SetObjectArrayElement(countriesJava, i, countryJava); + } + + return countriesJava; +} + +extern "C" [[maybe_unused]] JNIEXPORT jobjectArray JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_getAccountValidationErrors(JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id) +{ + using ErrorType = std::pair; + auto getErrorType = [&](const char* className, const char* ctrSig = "()V") -> ErrorType { + using namespace std::placeholders; + jclass errorClass = env->FindClass(className); + jmethodID ctrMID = env->GetMethodID(errorClass, "", ctrSig); + return std::make_pair(errorClass, ctrMID); + }; + auto newError = [&](const ErrorType& error, auto... args) { + return env->NewObject(error.first, error.second, args...); + }; + auto missingOTPError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$MissingOTP"); + auto corruptedOTPError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$CorruptedOTP"); + auto missingSEEPROMError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$MissingSEEPROM"); + auto corruptedSEEPROMError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$CorruptedSEEPROM"); + auto missingFileError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$MissingFile", "(Ljava/lang/String;)V"); + auto accountError = getErrorType("info/cemu/cemu/nativeinterface/NativeAccount$AccountError", "(I)V"); + auto baseErrorType = env->FindClass("info/cemu/cemu/nativeinterface/NativeAccount$OnlineValidationError"); + + uint32 persistentId = persistent_id; + auto account = Account::GetAccount(persistentId); + const auto validator = account.ValidateOnlineFiles(); + + if (account.GetPersistentId() != persistentId || validator.IsValid()) + { + return env->NewObjectArray(0, baseErrorType, nullptr); + } + + std::vector errors; + + if (validator.otp == OnlineValidator::FileState::Missing) + errors.push_back(newError(missingOTPError)); + else if (validator.otp == OnlineValidator::FileState::Corrupted) + errors.push_back(newError(corruptedOTPError)); + + if (validator.seeprom == OnlineValidator::FileState::Missing) + errors.push_back(newError(missingSEEPROMError)); + else if (validator.seeprom == OnlineValidator::FileState::Corrupted) + errors.push_back(newError(corruptedSEEPROMError)); + + if (!validator.missing_files.empty()) + { + int counter = 0; + for (const auto& missingFile : validator.missing_files) + { + errors.push_back(newError(missingFileError, JNIUtils::toJString(env, missingFile))); + + ++counter; + if (counter > 10) + { + break; + } + } + } + + if (!validator.valid_account && validator.account_error != OnlineAccountError::kNone) + { + errors.push_back(newError(accountError, validator.account_error)); + } + + jobjectArray errorsJava = env->NewObjectArray(errors.size(), baseErrorType, nullptr); + + for (int i = 0; i < errors.size(); i++) + { + env->SetObjectArrayElement(errorsJava, i, errors[i]); + } + + return errorsJava; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_isOTPPresent([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return NCrypto::OTP_IsPresent(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeAccount_isSEEPROMPresent([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return NCrypto::SEEPROM_IsPresent(); +} \ No newline at end of file diff --git a/src/android/app/src/main/cpp/NativeActiveSettings.cpp b/src/android/app/src/main/cpp/NativeActiveSettings.cpp new file mode 100644 index 00000000..2709c682 --- /dev/null +++ b/src/android/app/src/main/cpp/NativeActiveSettings.cpp @@ -0,0 +1,42 @@ +#include "JNIUtils.h" +#include "config/ActiveSettings.h" + +extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_getMLCPath(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + 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()); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_initializeActiveSettings(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring user_data_path, jstring data_path, jstring cache_path) +{ + std::string userDataPath = JNIUtils::toString(env, user_data_path); + std::string dataPath = JNIUtils::toString(env, data_path); + std::string cachePath = JNIUtils::toString(env, cache_path); + std::set failedWriteAccess; + ActiveSettings::SetPaths(false, {}, userDataPath, userDataPath, cachePath, dataPath, failedWriteAccess); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_setNativeLibDir(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring native_lib_dir) +{ + ActiveSettings::SetNativeLibPath(JNIUtils::toString(env, native_lib_dir)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_setInternalDir(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring internal_dir) +{ + ActiveSettings::SetInternalDir(JNIUtils::toString(env, internal_dir)); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_hasRequiredOnlineFiles(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return ActiveSettings::HasRequiredOnlineFiles(); +} \ No newline at end of file diff --git a/src/android/app/src/main/cpp/NativeEmulation.cpp b/src/android/app/src/main/cpp/NativeEmulation.cpp new file mode 100644 index 00000000..db83f94a --- /dev/null +++ b/src/android/app/src/main/cpp/NativeEmulation.cpp @@ -0,0 +1,261 @@ +#include "WindowSystem.h" +#include "JNIUtils.h" +#include "AndroidAudio.h" +#include "AndroidEmulatedController.h" +#include "AndroidFilesystemCallbacks.h" +#include "Cafe/HW/Latte/Core/LatteOverlay.h" +#include "Cafe/HW/Latte/Renderer/Vulkan/VulkanAPI.h" +#include "Cafe/HW/Latte/Renderer/Vulkan/VulkanRenderer.h" +#include "Cafe/CafeSystem.h" +#include "GameTitleLoader.h" +#include "input/ControllerFactory.h" +#include "input/InputManager.h" +#include "input/api/Android/AndroidController.h" +#include "input/api/Android/AndroidControllerProvider.h" +#include "config/ActiveSettings.h" +#include "Cemu/ncrypto/ncrypto.h" + +// forward declaration from main.cpp +void CemuCommonInit(); + +namespace NativeEmulation +{ + void initializeAudioDevices() + { + auto& config = GetConfig(); + if (!config.tv_device.empty()) + AndroidAudio::createAudioDevice(IAudioAPI::AudioAPI::Cubeb, config.tv_channels, config.tv_volume, true); + + if (!config.pad_device.empty()) + AndroidAudio::createAudioDevice(IAudioAPI::AudioAPI::Cubeb, config.pad_channels, config.pad_volume, false); + } + + void createCemuDirectories() + { + std::wstring mlc = ActiveSettings::GetMlcPath().generic_wstring(); + + // create sys/usr folder in mlc01 + const auto sysFolder = fs::path(mlc).append(L"sys"); + fs::create_directories(sysFolder); + + const auto usrFolder = fs::path(mlc).append(L"usr"); + fs::create_directories(usrFolder); + fs::create_directories(fs::path(usrFolder).append("title/00050000")); // base + fs::create_directories(fs::path(usrFolder).append("title/0005000c")); // dlc + fs::create_directories(fs::path(usrFolder).append("title/0005000e")); // update + + // Mii Maker save folders {0x500101004A000, 0x500101004A100, 0x500101004A200}, + fs::create_directories(fs::path(mlc).append(L"usr/save/00050010/1004a000/user/common/db")); + fs::create_directories(fs::path(mlc).append(L"usr/save/00050010/1004a100/user/common/db")); + fs::create_directories(fs::path(mlc).append(L"usr/save/00050010/1004a200/user/common/db")); + + // lang files + auto langDir = fs::path(mlc).append(L"sys/title/0005001b/1005c000/content"); + fs::create_directories(langDir); + + auto langFile = fs::path(langDir).append("language.txt"); + if (!fs::exists(langFile)) + { + std::ofstream file(langFile); + if (file.is_open()) + { + const char* langStrings[] = {"ja", "en", "fr", "de", "it", "es", "zh", "ko", "nl", "pt", "ru", "zh"}; + for (const char* lang : langStrings) + file << fmt::format(R"("{}",)", lang) << std::endl; + + file.flush(); + file.close(); + } + } + + auto countryFile = fs::path(langDir).append("country.txt"); + if (!fs::exists(countryFile)) + { + std::ofstream file(countryFile); + for (sint32 i = 0; i < 201; i++) + { + const char* countryCode = NCrypto::GetCountryAsString(i); + if (boost::iequals(countryCode, "NN")) + file << "NULL," << std::endl; + else + file << fmt::format(R"("{}",)", countryCode) << std::endl; + } + file.flush(); + file.close(); + } + + // cemu directories + const auto controllerProfileFolder = ActiveSettings::GetConfigPath(L"controllerProfiles").generic_wstring(); + if (!fs::exists(controllerProfileFolder)) + fs::create_directories(controllerProfileFolder); + + const auto memorySearcherFolder = ActiveSettings::GetUserDataPath(L"memorySearcher").generic_wstring(); + if (!fs::exists(memorySearcherFolder)) + fs::create_directories(memorySearcherFolder); + } + enum StartGameResult : sint32 + { + SUCCESSFUL = 0, + ERROR_GAME_BASE_FILES_NOT_FOUND = 1, + ERROR_NO_DISC_KEY = 2, + ERROR_NO_TITLE_TIK = 3, + ERROR_UNKNOWN = 4, + }; + StartGameResult startGame(const fs::path& launchPath) + { + TitleInfo launchTitle{launchPath}; + if (launchTitle.IsValid()) + { + // the title might not be in the TitleList, so we add it as a temporary entry + CafeTitleList::AddTitleFromPath(launchPath); + // title is valid, launch from TitleId + TitleId baseTitleId; + if (!CafeTitleList::FindBaseTitleId(launchTitle.GetAppTitleId(), baseTitleId)) + { + return ERROR_GAME_BASE_FILES_NOT_FOUND; + } + CafeSystem::PREPARE_STATUS_CODE r = CafeSystem::PrepareForegroundTitle(baseTitleId); + if (r != CafeSystem::PREPARE_STATUS_CODE::SUCCESS) + { + return ERROR_UNKNOWN; + } + } + else // if (launchTitle.GetFormat() == TitleInfo::TitleDataFormat::INVALID_STRUCTURE ) + { + // title is invalid, if it's an RPX/ELF we can launch it directly + // otherwise it's an error + CafeTitleFileType fileType = DetermineCafeSystemFileType(launchPath); + if (fileType == CafeTitleFileType::RPX || fileType == CafeTitleFileType::ELF) + { + CafeSystem::PREPARE_STATUS_CODE r = CafeSystem::PrepareForegroundTitleFromStandaloneRPX(launchPath); + if (r != CafeSystem::PREPARE_STATUS_CODE::SUCCESS) + { + return ERROR_UNKNOWN; + } + } + else if (launchTitle.GetInvalidReason() == TitleInfo::InvalidReason::NO_DISC_KEY) + { + return ERROR_NO_DISC_KEY; + } + else if (launchTitle.GetInvalidReason() == TitleInfo::InvalidReason::NO_TITLE_TIK) + { + return ERROR_NO_TITLE_TIK; + } + else + { + return ERROR_UNKNOWN; + } + } + CafeSystem::LaunchForegroundTitle(); + return SUCCESSFUL; + } +} // namespace NativeEmulation + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_setReplaceTVWithPadView([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean swapped) +{ + // Emulate pressing the TAB key for showing DRC instead of TV + WindowSystem::GetWindowInfo().set_keystate(static_cast(WindowSystem::PlatformKeyCodes::TAB), swapped); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_initializeEmulation([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + FilesystemAndroid::SetFilesystemCallbacks(std::make_shared()); + GetConfigHandle().SetFilename(ActiveSettings::GetConfigPath("settings.xml").generic_wstring()); + NativeEmulation::createCemuDirectories(); + NetworkConfig::LoadOnce(); + ActiveSettings::Init(); + LatteOverlay_init(); + CemuCommonInit(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_initializeRenderer(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject j_testSurface) +{ + InitializeGlobalVulkan(); + using ANativewindow_Ptr = std::unique_ptr; + JNIUtils::handleNativeException(env, [&]() { + cemu_assert_debug(j_testSurface != nullptr); + ANativewindow_Ptr testSurface(ANativeWindow_fromSurface(env, j_testSurface), &ANativeWindow_release); + WindowSystem::GetWindowInfo().window_main.surface = testSurface.get(); + WindowSystem::GetWindowInfo().window_main.backend = WindowSystem::WindowHandleInfo::Backend::Android; + g_renderer = std::make_unique(); + WindowSystem::GetWindowInfo().window_main.surface = nullptr; + }); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_setDPI([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jfloat dpi) +{ + auto& windowInfo = WindowSystem::GetWindowInfo(); + windowInfo.dpi_scale = windowInfo.pad_dpi_scale = dpi; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_clearSurface([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean is_main_canvas) +{ + if (!is_main_canvas) + { + auto renderer = static_cast(g_renderer.get()); + if (renderer) + renderer->StopUsingPadAndWait(); + } +} +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_recreateRenderSurface([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean is_main_canvas) +{ + // TODO +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_supportsLoadingCustomDriver([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return SupportsLoadingCustomDriver(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_setSurface(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject surface, jboolean is_main_canvas) +{ + JNIUtils::handleNativeException(env, [&]() { + cemu_assert_debug(surface != nullptr); + auto& windowHandleInfo = is_main_canvas ? WindowSystem::GetWindowInfo().canvas_main : WindowSystem::GetWindowInfo().canvas_pad; + windowHandleInfo.backend = WindowSystem::WindowHandleInfo::Backend::Android; + if (windowHandleInfo.surface) + { + ANativeWindow_release(static_cast(windowHandleInfo.surface)); + windowHandleInfo.surface = nullptr; + } + windowHandleInfo.surface = ANativeWindow_fromSurface(env, surface); + int width, height; + if (is_main_canvas) + WindowSystem::GetWindowPhysSize(width, height); + else + WindowSystem::GetPadWindowPhysSize(width, height); + VulkanRenderer::GetInstance()->InitializeSurface({width, height}, is_main_canvas); + }); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_setSurfaceSize([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint width, jint height, jboolean is_main_canvas) +{ + auto& windowInfo = WindowSystem::GetWindowInfo(); + if (is_main_canvas) + { + windowInfo.width = windowInfo.phys_width = width; + windowInfo.height = windowInfo.phys_height = height; + } + else + { + windowInfo.pad_width = windowInfo.phys_pad_width = width; + windowInfo.pad_height = windowInfo.phys_pad_height = height; + } +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeEmulation_startGame([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jstring launchPath) +{ + WindowSystem::GetWindowInfo().set_keystatesup(); + NativeEmulation::initializeAudioDevices(); + 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 new file mode 100644 index 00000000..181c1538 --- /dev/null +++ b/src/android/app/src/main/cpp/NativeGameTitles.cpp @@ -0,0 +1,543 @@ +#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 +{ + GameTitleLoader s_gameTitleLoader; + + std::list getCachesPaths(const TitleId& titleId) + { + std::list cachePaths{ + ActiveSettings::GetCachePath("shaderCache/driver/vk/{:016x}.bin", titleId), + ActiveSettings::GetCachePath("shaderCache/precompiled/{:016x}_spirv.bin", titleId), + ActiveSettings::GetCachePath("shaderCache/precompiled/{:016x}_gl.bin", titleId), + ActiveSettings::GetCachePath("shaderCache/transferable/{:016x}_shaders.bin", titleId), + ActiveSettings::GetCachePath("shaderCache/transferable/{:016x}_vkpipeline.bin", titleId), + }; + + cachePaths.remove_if([](const fs::path& cachePath) { + std::error_code ec; + return !fs::exists(cachePath, ec); + }); + + return cachePaths; + } + TitleId s_currentTitleId = 0; + GameProfile s_currentGameProfile{}; + void LoadGameProfile(TitleId titleId) + { + if (s_currentTitleId == titleId) + return; + s_currentTitleId = titleId; + s_currentGameProfile.Reset(); + s_currentGameProfile.Load(titleId); + } + + std::unique_ptr s_wuaConverter; +} // namespace NativeGameTitles + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_isLoadingSharedLibrariesForTitleEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + return NativeGameTitles::s_currentGameProfile.ShouldLoadSharedLibraries().value_or(false); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setLoadingSharedLibrariesForTitleEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id, jboolean enabled) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + NativeGameTitles::s_currentGameProfile.SetShouldLoadSharedLibraries(enabled); + NativeGameTitles::s_currentGameProfile.Save(game_title_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getCpuModeForTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + return static_cast(NativeGameTitles::s_currentGameProfile.GetCPUMode().value_or(CPUMode::Auto)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setCpuModeForTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id, jint cpu_mode) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + NativeGameTitles::s_currentGameProfile.SetCPUMode(static_cast(cpu_mode)); + NativeGameTitles::s_currentGameProfile.Save(game_title_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getThreadQuantumForTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + return NativeGameTitles::s_currentGameProfile.GetThreadQuantum(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setThreadQuantumForTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id, jint thread_quantum) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + NativeGameTitles::s_currentGameProfile.SetThreadQuantum(std::clamp(thread_quantum, 5000, 536870912)); + NativeGameTitles::s_currentGameProfile.Save(game_title_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_isShaderMultiplicationAccuracyForTitleEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + return NativeGameTitles::s_currentGameProfile.GetAccurateShaderMul() == AccurateShaderMulOption::True; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setShaderMultiplicationAccuracyForTitleEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id, jboolean enabled) +{ + NativeGameTitles::LoadGameProfile(game_title_id); + NativeGameTitles::s_currentGameProfile.SetAccurateShaderMul(enabled ? AccurateShaderMulOption::True : AccurateShaderMulOption::False); + NativeGameTitles::s_currentGameProfile.Save(game_title_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_titleHasShaderCacheFiles([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + return !NativeGameTitles::getCachesPaths(game_title_id).empty(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_removeShaderCacheFilesForTitle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id) +{ + std::error_code ec; + for (auto&& cacheFilePath : NativeGameTitles::getCachesPaths(game_title_id)) + fs::remove(cacheFilePath, ec); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setGameTitleFavorite([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong game_title_id, jboolean isFavorite) +{ + GetConfig().SetGameListFavorite(game_title_id, isFavorite); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setGameTitleLoadedCallback(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject game_title_loaded_callback) +{ + if (game_title_loaded_callback == nullptr) + { + NativeGameTitles::s_gameTitleLoader.setOnTitleLoaded(nullptr); + return; + } + jclass gameTitleLoadedCallbackClass = env->GetObjectClass(game_title_loaded_callback); + jmethodID onGameTitleLoadedMID = env->GetMethodID(gameTitleLoadedCallbackClass, "onGameTitleLoaded", "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$Game;)V"); + env->DeleteLocalRef(gameTitleLoadedCallbackClass); + NativeGameTitles::s_gameTitleLoader.setOnTitleLoaded(std::make_shared(onGameTitleLoadedMID, game_title_loaded_callback)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_reloadGameTitles([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + NativeGameTitles::s_gameTitleLoader.reloadGameTitles(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getInstalledGamesTitleIds(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return JNIUtils::createJavaLongArrayList(env, CafeTitleList::GetAllTitleIds()); +} + +class SaveListCallback +{ + private: + uint64 m_callbackIdSaveList; + JNIUtils::Scopedjobject m_saveListCallbackObj; + jmethodID m_onSaveDiscoveredMID; + JNIUtils::Scopedjclass m_saveDataClass; + jmethodID m_saveDataConstructorMID; + + void HandleSaveListCallback(CafeSaveListCallbackEvent* evt) + { + if (evt->eventType != CafeSaveListCallbackEvent::TYPE::SAVE_DISCOVERED) + return; + + ParsedMetaXml* metaInfo = evt->saveInfo->GetMetaInfo(); + if (!metaInfo) + return; + auto& saveInfo = *evt->saveInfo; + auto locationUID = std::hash()(metaInfo->GetTitleId()); + 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, " - "); + + JNIUtils::ScopedJNIENV env; + jstring nameJava = JNIUtils::toJString(env, name); + jstring pathJava = JNIUtils::toJString(env, saveInfo.GetPath()); + jobject saveData = env->NewObject( + *m_saveDataClass, + m_saveDataConstructorMID, + nameJava, + pathJava, + metaInfo->GetTitleId(), + locationUID, + metaInfo->GetTitleVersion(), + metaInfo->GetRegion()); + env->CallVoidMethod(*m_saveListCallbackObj, m_onSaveDiscoveredMID, saveData); + env->DeleteLocalRef(saveData); + env->DeleteLocalRef(nameJava); + env->DeleteLocalRef(pathJava); + } + + public: + SaveListCallback(jobject saveListCallbackObject) + { + JNIUtils::ScopedJNIENV env; + m_saveListCallbackObj = JNIUtils::Scopedjobject(saveListCallbackObject); + JNIUtils::Scopedjclass saveCallbacksClass{"info/cemu/cemu/nativeinterface/NativeGameTitles$SaveListCallback"}; + m_onSaveDiscoveredMID = env->GetMethodID(*saveCallbacksClass, "onSaveDiscovered", "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$SaveData;)V"); + m_saveDataClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeGameTitles$SaveData"); + m_saveDataConstructorMID = env->GetMethodID(*m_saveDataClass, "", "(Ljava/lang/String;Ljava/lang/String;JJSI)V"); + m_callbackIdSaveList = CafeSaveList::RegisterCallback( + [](CafeSaveListCallbackEvent* evt, void* ctx) { + static_cast(ctx)->HandleSaveListCallback(evt); + }, + this); + } + ~SaveListCallback() + { + CafeSaveList::UnregisterCallback(m_callbackIdSaveList); + } +}; + +class TitleListCallbacks +{ + private: + JNIUtils::Scopedjobject m_titleListCallbacksObj; + jmethodID m_onTitleDiscoveredMID; + jmethodID m_onTitleRemovedMID; + jmethodID m_titleDataConstructorMID; + JNIUtils::Scopedjclass m_titleDataClass; + uint64 m_callbackIdTitleList; + + void OnTitleDiscovered(TitleInfo& titleInfo) + { + if (titleInfo.IsCached()) + return; // the title list only displays non-cached entries + if (titleInfo.IsSystemDataTitle()) + return; // don't show system data titles for now + + 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, " - "); + + JNIUtils::ScopedJNIENV env; + jobject nameJava = JNIUtils::toJString(env, name); + jobject pathJava = JNIUtils::toJString(env, titleInfo.GetPath()); + jobject titleData = env->NewObject( + *m_titleDataClass, + m_titleDataConstructorMID, + nameJava, + pathJava, + titleInfo.GetAppTitleId(), + titleInfo.GetUID(), + titleInfo.GetAppTitleVersion(), + metaInfo->GetRegion(), + titleInfo.GetTitleType(), + titleInfo.GetFormat()); + + env->CallVoidMethod(*m_titleListCallbacksObj, m_onTitleDiscoveredMID, titleData); + + env->DeleteLocalRef(titleData); + env->DeleteLocalRef(nameJava); + env->DeleteLocalRef(pathJava); + } + + void OnTitleRemoved(TitleInfo& titleInfo) + { + JNIUtils::ScopedJNIENV()->CallVoidMethod(*m_titleListCallbacksObj, m_onTitleRemovedMID, titleInfo.GetUID()); + } + + void HandleTitleListCallback(CafeTitleListCallbackEvent* evt) + { + if (evt->eventType != CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED && + evt->eventType != CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED) + return; + + if (evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED) + { + OnTitleDiscovered(*evt->titleInfo); + } + else if (evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED) + { + OnTitleRemoved(*evt->titleInfo); + } + } + + public: + TitleListCallbacks(jobject titleListCallbacks) + { + JNIUtils::ScopedJNIENV env; + m_titleListCallbacksObj = JNIUtils::Scopedjobject(titleListCallbacks); + jclass titleCallbacksClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleListCallbacks"); + m_onTitleDiscoveredMID = env->GetMethodID(titleCallbacksClass, "onTitleDiscovered", "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$TitleData;)V"); + m_onTitleRemovedMID = env->GetMethodID(titleCallbacksClass, "onTitleRemoved", "(J)V"); + env->DeleteLocalRef(titleCallbacksClass); + m_titleDataClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleData"); + m_titleDataConstructorMID = env->GetMethodID(*m_titleDataClass, "", "(Ljava/lang/String;Ljava/lang/String;JJSIII)V"); + m_callbackIdTitleList = CafeTitleList::RegisterCallback( + [](CafeTitleListCallbackEvent* evt, void* ctx) { + static_cast(ctx)->HandleTitleListCallback(evt); + }, + this); + } + + ~TitleListCallbacks() + { + CafeTitleList::UnregisterCallback(m_callbackIdTitleList); + } +}; + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_refreshCafeTitleList([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + CafeTitleList::Refresh(); +} + +std::unique_ptr s_titleListCallbacks; + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setTitleListCallbacks([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jobject title_list_callbacks) +{ + if (title_list_callbacks == nullptr) + s_titleListCallbacks = nullptr; + else + s_titleListCallbacks = std::make_unique(title_list_callbacks); +} + +std::unique_ptr s_saveListCallback; + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setSaveListCallback([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jobject save_list_callback) +{ + if (save_list_callback == nullptr) + s_saveListCallback = nullptr; + else + s_saveListCallback = std::make_unique(save_list_callback); +} + +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 new file mode 100644 index 00000000..15badc5c --- /dev/null +++ b/src/android/app/src/main/cpp/NativeGraphicPacks.cpp @@ -0,0 +1,157 @@ +#include "Cafe/CafeSystem.h" +#include "config/CemuConfig.h" +#include "Cafe/GraphicPack/GraphicPack2.h" +#include "JNIUtils.h" + +namespace NativeGraphicPacks +{ + std::unordered_map<sint64, GraphicPackPtr> s_graphicPacks; + + void fillGraphicPacks() + { + s_graphicPacks.clear(); + auto graphicPacks = GraphicPack2::GetGraphicPacks(); + for (auto&& graphicPack : graphicPacks) + { + s_graphicPacks[reinterpret_cast<sint64>(graphicPack.get())] = graphicPack; + } + } + + void saveGraphicPackStateToConfig(GraphicPackPtr graphicPack) + { + auto& data = GetConfig(); + auto filename = _utf8ToPath(graphicPack->GetNormalizedPathString()); + if (data.graphic_pack_entries.contains(filename)) + data.graphic_pack_entries.erase(filename); + if (graphicPack->IsEnabled()) + { + data.graphic_pack_entries.try_emplace(filename); + auto& it = data.graphic_pack_entries[filename]; + // otherwise store all selected presets + for (const auto& preset : graphicPack->GetActivePresets()) + it.try_emplace(preset->category, preset->name); + } + else if (graphicPack->IsDefaultEnabled()) + { + // save that its disabled + data.graphic_pack_entries.try_emplace(filename); + auto& it = data.graphic_pack_entries[filename]; + it.try_emplace("_disabled", "false"); + } + } + + jobject getGraphicPresets(JNIEnv* env, GraphicPackPtr graphicPack, sint64 id) + { + auto graphicPackPresetClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGraphicPacks$GraphicPackPreset"); + auto graphicPackPresetCtorId = env->GetMethodID(graphicPackPresetClass, "<init>", "(JLjava/lang/String;Ljava/util/ArrayList;Ljava/lang/String;)V"); + + std::vector<std::string> order; + auto presets = graphicPack->GetCategorizedPresets(order); + + std::vector<jobject> presetsJobjects; + for (const auto& category : order) + { + const auto& entry = presets[category]; + // test if any preset is visible and update its status + if (std::none_of(entry.cbegin(), entry.cend(), [graphicPack](const auto& p) { return p->visible; })) + { + continue; + } + + jstring categoryJStr = category.empty() ? nullptr : JNIUtils::toJString(env, category); + std::vector<std::string> presetSelections; + std::optional<std::string> activePreset; + for (auto& pentry : entry) + { + if (!pentry->visible) + continue; + + presetSelections.push_back(pentry->name); + + if (pentry->active) + activePreset = pentry->name; + } + + jstring activePresetJstr = nullptr; + if (activePreset.has_value()) + activePresetJstr = JNIUtils::toJString(env, activePreset.value()); + else if (!presetSelections.empty()) + activePresetJstr = JNIUtils::toJString(env, presetSelections.front()); + auto presetJObject = env->NewObject(graphicPackPresetClass, graphicPackPresetCtorId, id, categoryJStr, JNIUtils::createJavaStringArrayList(env, presetSelections), activePresetJstr); + presetsJobjects.push_back(presetJObject); + } + return JNIUtils::createArrayList(env, presetsJobjects); + } +} // namespace NativeGraphicPacks + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_refreshGraphicPacks([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + if (!CafeSystem::IsTitleRunning()) + { + GraphicPack2::ClearGraphicPacks(); + GraphicPack2::LoadAll(); + NativeGraphicPacks::fillGraphicPacks(); + } +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_getGraphicPackBasicInfos(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + auto graphicPackInfoClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGraphicPacks$GraphicPackBasicInfo"); + 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 = 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); + graphicPackInfoJObjects.push_back(jGraphicPack); + } + return JNIUtils::createArrayList(env, graphicPackInfoJObjects); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_getGraphicPack(JNIEnv* env, [[maybe_unused]] jclass clazz, jlong id) +{ + auto graphicPackClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGraphicPacks$GraphicPack"); + 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 = JNIUtils::toJString(env, graphicPack->GetName()); + jstring graphicPackDescription = JNIUtils::toJString(env, graphicPack->GetDescription()); + return env->NewObject( + graphicPackClass, + graphicPackCtorId, + id, + graphicPack->IsEnabled(), + graphicPackName, + graphicPackDescription, + NativeGraphicPacks::getGraphicPresets(env, graphicPack, id)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_setGraphicPackActive([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong id, jboolean active) +{ + auto graphicPack = NativeGraphicPacks::s_graphicPacks.at(id); + graphicPack->SetEnabled(active); + NativeGraphicPacks::saveGraphicPackStateToConfig(graphicPack); +} + +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::toString(env, category); + auto graphicPack = NativeGraphicPacks::s_graphicPacks.at(id); + graphicPack->SetActivePreset(presetCategory, JNIUtils::toString(env, preset)); + NativeGraphicPacks::saveGraphicPackStateToConfig(graphicPack); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeGraphicPacks_getGraphicPackPresets(JNIEnv* env, [[maybe_unused]] jclass clazz, jlong id) +{ + return NativeGraphicPacks::getGraphicPresets(env, NativeGraphicPacks::s_graphicPacks.at(id), id); +} \ No newline at end of file diff --git a/src/android/app/src/main/cpp/NativeInput.cpp b/src/android/app/src/main/cpp/NativeInput.cpp new file mode 100644 index 00000000..3e2cea2a --- /dev/null +++ b/src/android/app/src/main/cpp/NativeInput.cpp @@ -0,0 +1,209 @@ +#include "JNIUtils.h" +#include "input/ControllerFactory.h" +#include "input/InputManager.h" +#include "input/api/Android/AndroidController.h" +#include "input/api/Android/AndroidControllerProvider.h" +#include "AndroidEmulatedController.h" + +namespace NativeInput +{ + WiiUMotionHandler s_wiiUMotionHandler{}; + long s_lastMotionTimestamp = 0; + + void onTouchEvent(sint32 x, sint32 y, bool isTV, std::optional<bool> status = {}) + { + auto& instance = InputManager::instance(); + auto& touchInfo = isTV ? instance.m_main_mouse : instance.m_pad_mouse; + std::scoped_lock lock(touchInfo.m_mutex); + touchInfo.position = {x, y}; + if (status.has_value()) + touchInfo.left_down = touchInfo.left_down_toggle = status.value(); + } +} // 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::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); +} + +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::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); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_setControllerType([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index, jint emulated_controller_type) +{ + auto type = static_cast<EmulatedController::Type>(emulated_controller_type); + auto& androidEmulatedController = AndroidEmulatedController::getAndroidEmulatedController(index); + if (EmulatedController::Type::VPAD <= type && type < EmulatedController::Type::MAX) + androidEmulatedController.setType(type); + else + androidEmulatedController.setDisabled(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_getControllerType([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index) +{ + auto emulatedController = AndroidEmulatedController::getAndroidEmulatedController(index).getEmulatedController(); + if (emulatedController) + return emulatedController->type(); + throw std::runtime_error(fmt::format("can't get type for emulated controller {}", index)); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_getWPADControllersCount([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + int wpadCount = 0; + for (size_t i = 0; i < InputManager::kMaxController; i++) + { + auto emulatedController = AndroidEmulatedController::getAndroidEmulatedController(i).getEmulatedController(); + if (!emulatedController) + continue; + if (emulatedController->type() != EmulatedController::Type::VPAD) + ++wpadCount; + } + return wpadCount; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_getVPADControllersCount([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + int vpadCount = 0; + for (size_t i = 0; i < InputManager::kMaxController; i++) + { + auto emulatedController = AndroidEmulatedController::getAndroidEmulatedController(i).getEmulatedController(); + if (!emulatedController) + continue; + if (emulatedController->type() == EmulatedController::Type::VPAD) + ++vpadCount; + } + return vpadCount; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_setVPADScreenToggle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index, jboolean enabled) +{ + auto emulatedController = AndroidEmulatedController::getAndroidEmulatedController(index).getEmulatedController(); + if (emulatedController == nullptr || emulatedController->type() != EmulatedController::Type::VPAD) + throw std::runtime_error(fmt::format("Invalid controller type for controller {}, expected VPAD", index)); + static_cast<VPADController*>(emulatedController.get())->set_screen_toggle(enabled); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_getVPADScreenToggle([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index) +{ + auto emulatedController = AndroidEmulatedController::getAndroidEmulatedController(index).getEmulatedController(); + if (emulatedController == nullptr || emulatedController->type() != EmulatedController::Type::VPAD) + throw std::runtime_error(fmt::format("Invalid controller type for controller {}, expected VPAD", index)); + return static_cast<VPADController*>(emulatedController.get())->is_screen_active_toggle(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_isControllerDisabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index) +{ + return AndroidEmulatedController::getAndroidEmulatedController(index).getEmulatedController() == nullptr; +} + +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::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); +} + +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 JNIUtils::toJString(env, mapping.value_or("")); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_clearControllerMapping([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint index, jint mapping_id) +{ + AndroidEmulatedController::getAndroidEmulatedController(index).clearMapping(mapping_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_getControllerMappings(JNIEnv* env, [[maybe_unused]] jclass clazz, jint index) +{ + jclass hashMapClass = env->FindClass("java/util/HashMap"); + jmethodID hashMapConstructor = env->GetMethodID(hashMapClass, "<init>", "()V"); + jmethodID hashMapPut = env->GetMethodID(hashMapClass, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + jclass integerClass = env->FindClass("java/lang/Integer"); + jmethodID integerConstructor = env->GetMethodID(integerClass, "<init>", "(I)V"); + jobject hashMapObj = env->NewObject(hashMapClass, hashMapConstructor); + auto mappings = AndroidEmulatedController::getAndroidEmulatedController(index).getMappings(); + for (const auto& pair : mappings) + { + jint key = pair.first; + jstring buttonName = JNIUtils::toJString(env, pair.second); + jobject mappingId = env->NewObject(integerClass, integerConstructor, key); + env->CallObjectMethod(hashMapObj, hashMapPut, mappingId, buttonName); + } + return hashMapObj; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onTouchDown([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint x, jint y, jboolean isTV) +{ + NativeInput::onTouchEvent(x, y, isTV, true); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onTouchUp([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint x, jint y, jboolean isTV) +{ + NativeInput::onTouchEvent(x, y, isTV, false); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onTouchMove([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint x, jint y, jboolean isTV) +{ + NativeInput::onTouchEvent(x, y, isTV); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onMotion([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong timestamp, jfloat gyroX, jfloat gyroY, jfloat gyroZ, jfloat accelX, jfloat accelY, jfloat accelZ) +{ + static constexpr float METERS_PER_SECOND_SQ_TO_G = 1.f / 9.81f; + float deltaTime = (timestamp - NativeInput::s_lastMotionTimestamp) * 1e-9f; + NativeInput::s_wiiUMotionHandler.processMotionSample(deltaTime, gyroX, gyroY, gyroZ, accelX * METERS_PER_SECOND_SQ_TO_G, accelY * METERS_PER_SECOND_SQ_TO_G, accelZ * METERS_PER_SECOND_SQ_TO_G); + NativeInput::s_lastMotionTimestamp = timestamp; + auto& deviceMotion = InputManager::instance().m_device_motion; + std::scoped_lock lock{deviceMotion.m_mutex}; + deviceMotion.m_motion_sample = NativeInput::s_wiiUMotionHandler.getMotionSample(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_setMotionEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean motionEnabled) +{ + auto& deviceMotion = InputManager::instance().m_device_motion; + std::scoped_lock lock{deviceMotion.m_mutex}; + deviceMotion.m_device_motion_enabled = motionEnabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onOverlayButton([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint controllerIndex, jint mappingId, jboolean state) +{ + AndroidEmulatedController::getAndroidEmulatedController(controllerIndex).setButtonValue(mappingId, state); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeInput_onOverlayAxis([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint controllerIndex, jint mappingId, jfloat value) +{ + AndroidEmulatedController::getAndroidEmulatedController(controllerIndex).setAxisValue(mappingId, value); +} \ No newline at end of file diff --git a/src/android/app/src/main/cpp/NativeLib.cpp b/src/android/app/src/main/cpp/NativeLib.cpp new file mode 100644 index 00000000..22c6678f --- /dev/null +++ b/src/android/app/src/main/cpp/NativeLib.cpp @@ -0,0 +1,7 @@ +#include "JNIUtils.h" + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, [[maybe_unused]] void* reserved) +{ + JNIUtils::g_jvm = vm; + return JNI_VERSION_1_6; +} diff --git a/src/android/app/src/main/cpp/NativeLocalization.cpp b/src/android/app/src/main/cpp/NativeLocalization.cpp new file mode 100644 index 00000000..3067098f --- /dev/null +++ b/src/android/app/src/main/cpp/NativeLocalization.cpp @@ -0,0 +1,42 @@ +#include "JNIUtils.h" + +namespace NativeLocalization +{ + std::unordered_map<std::string_view, std::string> g_messages; + + std::string Translate(std::string_view msgId) + { + if (auto message = g_messages.find(msgId); message != g_messages.end()) + { + return message->second; + } + + return std::string{msgId}; + } +} // namespace NativeLocalization + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeLocalization_setTranslations(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject translations) +{ + NativeLocalization::g_messages.clear(); + + jclass mapClass = env->GetObjectClass(translations); + jmethodID keySetMethodId = env->GetMethodID(mapClass, "keySet", "()Ljava/util/Set;"); + jmethodID getMethodId = env->GetMethodID(mapClass, "get", "(Ljava/lang/Object;)Ljava/lang/Object;"); + jobject keySet = env->CallObjectMethod(translations, keySetMethodId); + jclass setClass = env->GetObjectClass(keySet); + jmethodID toArrayMethodId = env->GetMethodID(setClass, "toArray", "()[Ljava/lang/Object;"); + auto keyArray = static_cast<jobjectArray>(env->CallObjectMethod(keySet, toArrayMethodId)); + jint size = env->GetArrayLength(keyArray); + + for (jint i = 0; i < size; i++) + { + auto keyJava = static_cast<jstring>(env->GetObjectArrayElement(keyArray, i)); + std::string key = JNIUtils::toString(env, keyJava); + auto translationJava = static_cast<jstring>(env->CallObjectMethod(translations, getMethodId, keyJava)); + std::string translation = JNIUtils::toString(env, translationJava); + NativeLocalization::g_messages[key] = translation; + } + + SetTranslationCallback(NativeLocalization::Translate); +} \ No newline at end of file diff --git a/src/android/app/src/main/cpp/NativeLogging.cpp b/src/android/app/src/main/cpp/NativeLogging.cpp new file mode 100644 index 00000000..02d0dc87 --- /dev/null +++ b/src/android/app/src/main/cpp/NativeLogging.cpp @@ -0,0 +1,17 @@ +#include "Common/ExceptionHandler/ExceptionHandler.h" +#include "JNIUtils.h" + +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::toString(env, message)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeLogging_crashLog(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring stacktrace) +{ + if (!CrashLog_Create()) + return; // give up if crashlog was already created + CrashLog_WriteLine("Unhandled exception from java code"); + 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 new file mode 100644 index 00000000..64a62014 --- /dev/null +++ b/src/android/app/src/main/cpp/NativeSettings.cpp @@ -0,0 +1,390 @@ +#include "JNIUtils.h" +#include "audio/IAudioAPI.h" +#include "config/CemuConfig.h" +#include "config/NetworkSettings.h" + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getOverlayPosition([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return static_cast<jint>(GetConfig().overlay.position); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayPosition([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint position) +{ + GetConfig().overlay.position = static_cast<ScreenPosition>(position); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getOverlayTextScalePercentage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.text_scale; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayTextScalePercentage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint scalePercentage) +{ + GetConfig().overlay.text_scale = scalePercentage; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayFPSEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.fps; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayFPSEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.fps = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayDrawCallsPerFrameEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.drawcalls; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayDrawCallsPerFrameEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.drawcalls = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayCPUUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.cpu_usage; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayCPUUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.cpu_usage = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayCPUPerCoreUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.cpu_per_core_usage; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayCPUPerCoreUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.cpu_per_core_usage = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayRAMUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.ram_usage; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayRAMUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.ram_usage = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayVRAMUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.vram_usage; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayVRAMUsageEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.vram_usage = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isOverlayDebugEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().overlay.debug; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setOverlayDebugEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().overlay.debug = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getNotificationsPosition([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return static_cast<jint>(GetConfig().notification.position); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setNotificationsPosition([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint position) +{ + GetConfig().notification.position = static_cast<ScreenPosition>(position); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getNotificationsTextScalePercentage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().notification.text_scale; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setNotificationsTextScalePercentage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint scalePercentage) +{ + GetConfig().notification.text_scale = scalePercentage; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isNotificationControllerProfilesEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().notification.controller_profiles; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setNotificationControllerProfilesEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().notification.controller_profiles = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isNotificationShaderCompilerEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().notification.shader_compiling; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setNotificationShaderCompilerEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().notification.shader_compiling = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_isNotificationFriendListEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().notification.friends; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setNotificationFriendListEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().notification.friends = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_addGamesPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) +{ + auto& gamePaths = GetConfig().game_paths; + auto gamePath = JNIUtils::toString(env, uri); + if (std::any_of(gamePaths.begin(), gamePaths.end(), [&](const auto& path) { return path == gamePath; })) + return; + gamePaths.push_back(gamePath); +} + +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::toString(env, uri); + auto& gamePaths = GetConfig().game_paths; + std::erase_if(gamePaths, [&](const auto& path) { return path == gamePath; }); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getGamesPaths(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return JNIUtils::createJavaStringArrayList(env, GetConfig().game_paths); +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAsyncShaderCompile([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().async_compile; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAsyncShaderCompile([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().async_compile = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getVsyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().vsync; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setVsyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint vsync_mode) +{ + GetConfig().vsync = vsync_mode; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAccurateBarriers([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().vk_accurate_barriers; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setUpscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint upscaling_filter) +{ + GetConfig().upscale_filter = upscaling_filter; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getUpscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().upscale_filter; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setDownscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint downscaling_filter) +{ + GetConfig().downscale_filter = downscaling_filter; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getDownscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().downscale_filter; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setFullscreenScaling([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint fullscreen_scaling) +{ + GetConfig().fullscreen_scaling = fullscreen_scaling; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getFullscreenScaling([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().fullscreen_scaling; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAccurateBarriers([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled) +{ + GetConfig().vk_accurate_barriers = enabled; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAudioDeviceEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean tv) +{ + const auto& device = tv ? GetConfig().tv_device : GetConfig().pad_device; + return !device.empty(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioDeviceEnabled([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean enabled, jboolean tv) +{ + auto& device = tv ? GetConfig().tv_device : GetConfig().pad_device; + if (enabled) + device = L"Default"; + else + device.clear(); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAudioDeviceChannels([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean tv) +{ + const auto& deviceChannels = tv ? GetConfig().tv_channels : GetConfig().pad_channels; + return deviceChannels; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioDeviceChannels([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint channels, jboolean tv) +{ + auto& deviceChannels = tv ? GetConfig().tv_channels : GetConfig().pad_channels; + deviceChannels = static_cast<AudioChannels>(channels); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAudioDeviceVolume([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jboolean tv) +{ + const auto& deviceVolume = tv ? GetConfig().tv_volume : GetConfig().pad_volume; + return deviceVolume; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioDeviceVolume([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint volume, jboolean tv) +{ + auto& deviceVolume = tv ? GetConfig().tv_volume : GetConfig().pad_volume; + deviceVolume = volume; +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAudioLatency([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return GetConfig().audio_delay * 12; +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioLatency([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint latency) +{ + sint32 audioDelay = latency / 12; + GetConfig().audio_delay = audioDelay; + IAudioAPI::SetAudioDelay(audioDelay); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getConsoleLanguage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return static_cast<jint>(GetConfig().console_language.GetValue()); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setConsoleLanguage([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint console_language) +{ + GetConfig().console_language = static_cast<CafeConsoleLanguage>(console_language); +} + +extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getCustomDriverPath(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + std::string customDriverPath = GetConfig().custom_driver_path; + if (customDriverPath.empty()) + return nullptr; + return JNIUtils::toJString(env, customDriverPath); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setCustomDriverPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring custom_driver_path) +{ + GetConfig().custom_driver_path = JNIUtils::toString(env, custom_driver_path); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAccountNetworkService([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id) +{ + return static_cast<jint>(GetConfig().GetAccountNetworkService(persistent_id)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAccountNetworkService([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id, jint network_service) +{ + GetConfig().SetAccountSelectedService(persistent_id, static_cast<NetworkService>(network_service)); +} + +extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_getAccountPersistentId([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return static_cast<jint>(GetConfig().account.m_persistent_id); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_setAccountPersistentId([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint persistent_id) +{ + GetConfig().account.m_persistent_id = persistent_id; +} + +extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_hasCustomNetworkConfiguration([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return NetworkConfig::XMLExists(); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSettings_saveSettings([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + GetConfigHandle().Save(); +} diff --git a/src/android/app/src/main/cpp/NativeSwkbd.cpp b/src/android/app/src/main/cpp/NativeSwkbd.cpp new file mode 100644 index 00000000..c711114a --- /dev/null +++ b/src/android/app/src/main/cpp/NativeSwkbd.cpp @@ -0,0 +1,56 @@ +#include "JNIUtils.h" + +#include "Cafe/OS/libs/swkbd/swkbd.h" +#include "AndroidSwkbdCallbacks.h" + +namespace NativeSwkbd +{ + std::shared_ptr<swkbd::swkbdCallbacks> s_swkbdCallbacks; + std::string s_currentInputText; + struct StrDiffs + { + size_t newTextStartIndex; + size_t numberOfCharacterToDelete; + }; + StrDiffs getStringDiffs(const std::string& newText, const std::string& currentText) + { + if (newText.length() < currentText.length() && currentText.starts_with(newText)) + return {newText.length(), currentText.length() - newText.length()}; + if (newText.length() >= currentText.length() && newText.starts_with(currentText)) + return {currentText.length(), 0}; + return {0, currentText.length()}; + } +} // namespace NativeSwkbd + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSwkbd_initializeSwkbd([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + if (NativeSwkbd::s_swkbdCallbacks != nullptr) + return; + NativeSwkbd::s_swkbdCallbacks = std::make_shared<AndroidSwkbdCallbacks>(); + swkbd::setSwkbdCallbacks(NativeSwkbd::s_swkbdCallbacks); +} + +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::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::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); + for (size_t i = stringDiff.newTextStartIndex; i < text.length(); i++) + swkbd::keyInput(text.at(i)); + NativeSwkbd::s_currentInputText = std::move(text); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_cemu_nativeinterface_NativeSwkbd_onFinishedInputEdit([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + swkbd::keyInput(swkbd::RETURN_KEYCODE); +} \ No newline at end of file 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..bca728bf --- /dev/null +++ b/src/android/app/src/main/cpp/WuaConverter.cpp @@ -0,0 +1,208 @@ +#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; + } + stdx::scope_exit fdCleanup([fd](){ close(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/CemuApplication.kt b/src/android/app/src/main/java/info/cemu/cemu/CemuApplication.kt new file mode 100644 index 00000000..651de7de --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/CemuApplication.kt @@ -0,0 +1,151 @@ +package info.cemu.cemu + +import android.app.Application +import info.cemu.cemu.common.android.context.internalFolder +import info.cemu.cemu.common.ui.localization.setLanguage +import info.cemu.cemu.common.ui.localization.setTranslations +import info.cemu.cemu.nativeinterface.NativeActiveSettings.initializeActiveSettings +import info.cemu.cemu.nativeinterface.NativeActiveSettings.setInternalDir +import info.cemu.cemu.nativeinterface.NativeActiveSettings.setNativeLibDir +import info.cemu.cemu.nativeinterface.NativeEmulation.initializeEmulation +import info.cemu.cemu.nativeinterface.NativeEmulation.setDPI +import info.cemu.cemu.nativeinterface.NativeGraphicPacks.refreshGraphicPacks +import info.cemu.cemu.nativeinterface.NativeLogging.crashLog +import info.cemu.cemu.nativeinterface.NativeSwkbd.initializeSwkbd +import info.cemu.cemu.common.settings.SettingsManager +import info.cemu.cemu.nativeinterface.NativeFiles +import java.io.File +import java.io.IOException +import java.io.PrintWriter +import java.io.StringWriter +import java.util.regex.Pattern + +class CemuApplication : Application() { + override fun onCreate() { + super.onCreate() + + configureExceptionHandler() + + SettingsManager.initialize(this) + + NativeFiles.initialize(contentResolver) + + initializeTranslations() + + initializeCemu() + + saveDataFiles() + } + + private fun initializeTranslations() { + setTranslations(this) + setLanguage(SettingsManager.guiSettings.language, this) + } + + private fun saveDataFiles() { + val dataFolder = File(internalCemuDataFolder) + + if (!dataFolder.exists() && !dataFolder.mkdirs()) { + return + } + + val hashFileName = "hash.txt" + val hashFile = dataFolder.resolve(hashFileName) + val oldHash = if (hashFile.isFile) hashFile.readText() else "invalid" + + val newHash = try { + assets.open(hashFileName).use { it.reader().readText() } + } catch (_: IOException) { + return + } + + if (oldHash == newHash) { + return + } + + dataFolder.deleteRecursively() + dataFolder.mkdirs() + dataFolder.resolve(hashFileName).writeText(newHash) + + fun traverseAssets(path: String = ""): Iterator<String> = iterator { + val assetFiles = assets.list(path) ?: return@iterator + + if (assetFiles.isEmpty()) { + yield(path) + } + + for (assetFile in assetFiles) { + val assetPath = path + (if (path == "") "" else "/") + assetFile + for (file in traverseAssets(assetPath)) { + yield(file) + } + } + } + + val filePatterns = arrayOf( + Pattern.compile("gameProfiles/.*"), + Pattern.compile("resources/.*"), + ) + + fun isFileValid(file: String): Boolean { + return filePatterns.any { pattern -> pattern.matcher(file).matches() } + } + + for (assetFile in traverseAssets()) { + if (!isFileValid(assetFile)) { + continue + } + + val outFile = dataFolder.resolve(assetFile) + outFile.parentFile?.mkdirs() + assets.open(assetFile) + .use { asset -> outFile.outputStream().use { out -> asset.copyTo(out) } } + } + } + + private fun configureExceptionHandler() { + if (DefaultUncaughtExceptionHandler == null) { + DefaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + } + Thread.setDefaultUncaughtExceptionHandler { thread: Thread, exception: Throwable -> + val stringWriter = StringWriter() + val printWriter = PrintWriter(stringWriter) + exception.printStackTrace(printWriter) + val stacktrace = stringWriter.toString() + crashLog(stacktrace) + DefaultUncaughtExceptionHandler!!.uncaughtException( + thread, + exception + ) + } + } + + private fun initializeCemu() { + val displayMetrics = resources.displayMetrics + setDPI(displayMetrics.density) + initializeActiveSettings( + userDataPath = internalCemuUserFolder, + dataPath = internalCemuDataFolder, + cachePath = internalCemuUserFolder, + ) + setNativeLibDir(applicationInfo.nativeLibraryDir) + setInternalDir(dataDir.absolutePath) + initializeEmulation() + initializeSwkbd() + refreshGraphicPacks() + } + + private val internalCemuDataFolder: String + get() = internalFolder().resolve("data").toString() + + private val internalCemuUserFolder: String + get() = internalFolder().toString() + + companion object { + init { + System.loadLibrary("CemuAndroid") + } + + private var DefaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null + } +} 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 new file mode 100644 index 00000000..4f204812 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/MainActivity.kt @@ -0,0 +1,275 @@ +package info.cemu.cemu + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.os.Bundle +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import info.cemu.cemu.about.AboutCemuRoute +import info.cemu.cemu.about.aboutCemuNavigation +import info.cemu.cemu.common.ui.components.ActivityContent +import info.cemu.cemu.common.ui.localization.TranslatableContent +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.emulation.EmulationActivity +import info.cemu.cemu.gamelist.GameListRoute +import info.cemu.cemu.gamelist.gameListNavigation +import info.cemu.cemu.graphicpacks.GraphicPacksRoute +import info.cemu.cemu.graphicpacks.graphicPacksNavigation +import info.cemu.cemu.nativeinterface.NativeActiveSettings +import info.cemu.cemu.nativeinterface.NativeGameTitles.Game +import info.cemu.cemu.nativeinterface.NativeSettings +import info.cemu.cemu.provider.DocumentsProvider +import info.cemu.cemu.settings.SettingsRoute +import info.cemu.cemu.settings.settingsNavigation +import info.cemu.cemu.titlemanager.TitleManagerRoute +import info.cemu.cemu.titlemanager.titleManagerNavigation +import java.io.File + +import android.graphics.drawable.Icon as AndroidIcon + + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TranslatableContent { + ActivityContent { + MainNav() + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + NativeSettings.saveSettings() + } + + override fun onPause() { + super.onPause() + NativeSettings.saveSettings() + } +} + +@Composable +private fun MainNav() { + val navController = rememberNavController() + val context = LocalContext.current + + NavHost( + navController = navController, + startDestination = GameListRoute, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None } + ) { + gameListNavigation( + navController = navController, + startGame = { startGame(context, it) }, + createShortcut = { createShortcutForGame(context, it) } + ) { + GameListToolBarActionsMenu( + goToSettings = { navController.navigate(SettingsRoute) }, + goToTitleManager = { navController.navigate(TitleManagerRoute) }, + goToGraphicPacks = { navController.navigate(GraphicPacksRoute) }, + goToAboutCemu = { navController.navigate(AboutCemuRoute) } + ) + } + settingsNavigation(navController) + titleManagerNavigation(navController) + graphicPacksNavigation(navController) + aboutCemuNavigation(navController) + } +} + +@Composable +private fun GameListToolBarActionsMenu( + goToSettings: () -> Unit, + goToTitleManager: () -> Unit, + goToGraphicPacks: () -> Unit, + goToAboutCemu: () -> Unit, +) { + var expandMenu by remember { mutableStateOf(false) } + val context = LocalContext.current + + @Composable + fun DropdownMenuItem(onClick: () -> Unit, text: String) { + DropdownMenuItem( + onClick = { + onClick() + expandMenu = false + }, + text = { Text(text) }, + ) + } + IconButton( + modifier = Modifier.padding(end = 8.dp), + onClick = { expandMenu = true }, + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } + DropdownMenu( + expanded = expandMenu, + onDismissRequest = { expandMenu = false } + ) { + DropdownMenuItem( + onClick = goToSettings, + text = tr("Settings") + ) + DropdownMenuItem( + onClick = goToGraphicPacks, + text = tr("Graphic packs") + ) + DropdownMenuItem( + onClick = goToTitleManager, + text = tr("Title manager") + ) + DropdownMenuItem( + onClick = { openCemuFolder(context) }, + text = tr("Open Cemu folder") + ) + DropdownMenuItem( + onClick = { shareLogFile(context) }, + text = tr("Share log file"), + ) + DropdownMenuItem( + onClick = goToAboutCemu, + text = tr("About Cemu"), + ) + } +} + +private fun startGame(context: Context, game: Game) { + Intent( + context, + EmulationActivity::class.java + ).apply { + putExtra(EmulationActivity.EXTRA_LAUNCH_PATH, game.path) + context.startActivity(this) + } +} + +private fun shareLogFile(context: Context) { + val logFileName = "log.txt" + val logFile = File(NativeActiveSettings.getUserDataPath()).resolve(logFileName) + + if (!logFile.isFile) { + Toast.makeText(context, tr("Log file doesn't exist"), Toast.LENGTH_LONG).show() + return + } + + val fileUri = DocumentsContract.buildDocumentUri( + DocumentsProvider.AUTHORITY, + DocumentsProvider.ROOT_ID + "/$logFileName" + ) + + val documentFile = DocumentFile.fromSingleUri(context, fileUri) ?: return + + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(documentFile.uri, "text/plain") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, documentFile.uri) + + context.startActivity(Intent.createChooser(intent, null)) +} + +private fun openCemuFolder(context: Context) { + try { + val intent = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_DEFAULT) + .addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + or Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + intent.data = DocumentsContract.buildRootUri( + DocumentsProvider.AUTHORITY, + DocumentsProvider.ROOT_ID + ) + context.startActivity(intent) + } catch (_: Exception) { + Toast.makeText(context, tr("Could not open Cemu folder"), Toast.LENGTH_LONG).show() + } +} + + +private fun createShortcutForGame( + context: Context, + game: Game, +) { + fun onFailedToCreateShortcut() { + Toast.makeText(context, tr("Couldn't create shortcut for game"), Toast.LENGTH_LONG).show() + } + + try { + val shortcutManager = context.getSystemService( + ShortcutManager::class.java + ) + if (!shortcutManager.isRequestPinShortcutSupported) { + onFailedToCreateShortcut() + return + } + + val icon = game.icon?.asAndroidBitmap().let { + if (it != null) AndroidIcon.createWithBitmap(it) + else AndroidIcon.createWithResource(context, R.mipmap.ic_launcher) + } + + val intent = Intent( + context, + EmulationActivity::class.java + ) + intent.action = Intent.ACTION_VIEW + intent.putExtra(EmulationActivity.EXTRA_LAUNCH_PATH, game.path) + + val pinShortcutInfo = ShortcutInfo.Builder(context, game.titleId.toString()) + .setShortLabel(game.name!!) + .setIntent(intent) + .setIcon(icon) + .build() + + val pinnedShortcutCallbackIntent = + shortcutManager.createShortcutResultIntent(pinShortcutInfo) + + val successCallback = PendingIntent.getBroadcast( + context, + 0, + pinnedShortcutCallbackIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + shortcutManager.requestPinShortcut(pinShortcutInfo, successCallback.intentSender) + } catch (_: Exception) { + onFailedToCreateShortcut() + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuNavigation.kt b/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuNavigation.kt new file mode 100644 index 00000000..459a326d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuNavigation.kt @@ -0,0 +1,15 @@ +package info.cemu.cemu.about + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +object AboutCemuRoute + +fun NavGraphBuilder.aboutCemuNavigation(navController: NavHostController) { + composable<AboutCemuRoute> { + AboutCemuScreen { navController.popBackStack() } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuScreen.kt new file mode 100644 index 00000000..37a42d7f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/about/AboutCemuScreen.kt @@ -0,0 +1,261 @@ +package info.cemu.cemu.about + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults +import com.mikepenz.aboutlibraries.ui.compose.android.rememberLibraries +import com.mikepenz.aboutlibraries.ui.compose.m3.LicenseDialog +import com.mikepenz.aboutlibraries.ui.compose.m3.LicenseDialogBody +import com.mikepenz.aboutlibraries.ui.compose.m3.libraryColors +import com.mikepenz.aboutlibraries.ui.compose.util.author +import info.cemu.cemu.BuildConfig +import info.cemu.cemu.R +import info.cemu.cemu.common.ui.components.ScreenContentLazy +import info.cemu.cemu.common.ui.localization.tr +import kotlinx.collections.immutable.persistentListOf + +typealias AboutLibrary = com.mikepenz.aboutlibraries.entity.Library + +@Composable +fun AboutCemuScreen(navigateBack: () -> Unit) { + var openLicenseDialog by remember { mutableStateOf<AboutLibrary?>(null) } + val libraries by rememberLibraries(R.raw.aboutlibraries) + + ScreenContentLazy( + appBarText = tr("About Cemu"), + navigateBack = navigateBack, + contentModifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentVerticalArrangement = Arrangement.spacedBy(16.dp), + ) { + aboutCemuSection() + disclaimerSection() + nativeLibrariesSection() + kotlinLibrariesSection( + libraries = libraries, + onOpenLicense = { lib -> openLicenseDialog = lib }, + ) + } + + openLicenseDialog?.let { library -> + LicenseDialog( + library = library, + body = { library, modifier -> + LicenseDialogBody( + library = library, + colors = LibraryDefaults.libraryColors(), + modifier = Modifier.padding(4.dp) + ) + }, + onDismiss = { openLicenseDialog = null }, + confirmText = tr("OK"), + ) + } +} + +private fun LazyListScope.aboutCemuSection() { + item { + AboutSection { + Text( + text = stringResource(R.string.app_name), + fontSize = 32.sp, + ) + Text( + text = tr("Version: {0}", BuildConfig.VERSION_NAME), + fontSize = 18.sp, + ) + Text( + text = tr( + "Original authors: {0}", + stringResource(R.string.cemu_original_authors) + ), + fontSize = 18.sp, + ) + CemuWebsite() + } + } +} + +private fun LazyListScope.disclaimerSection() { + item { + AboutSection { + Text( + text = tr("Cemu is a Wii U emulator.\n\nWii and Wii U are trademarks of Nintendo.\nCemu is not affiliated with Nintendo."), + fontSize = 18.sp + ) + } + } +} + +private fun LazyListScope.nativeLibrariesSection() { + item { + AboutSection { + Text( + text = tr("Used libraries:"), + fontSize = 24.sp, + ) + UsedLibraries.forEach { + Library(it) + } + } + } +} + +private fun LazyListScope.kotlinLibrariesSection( + libraries: Libs?, + onOpenLicense: (AboutLibrary) -> Unit, +) { + val libs = libraries?.libraries ?: persistentListOf() + item { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = tr("Kotlin libraries:"), + fontSize = 24.sp, + ) + } + items(items = libs) { lib -> + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .padding(horizontal = 8.dp) + .clickable { onOpenLicense(lib) }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = lib.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text(lib.artifactVersion ?: "") + } + Text(lib.author) + FlowRow(horizontalArrangement = Arrangement.SpaceBetween) { + lib.licenses.forEach { license -> + Card { + Text( + text = license.name, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } + } +} + +@Composable +private fun CemuWebsite() { + Text( + buildAnnotatedString { + withLink( + LinkAnnotation.Url( + stringResource(R.string.cemu_website), + TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.Underline, + ), + ) + ) + ) { + append(stringResource(R.string.cemu_website)) + } + } + ) +} + +@Composable +private fun AboutSection(content: @Composable ColumnScope.() -> Unit) { + SelectionContainer { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(8.dp), + content = content, + ) + } +} + +@Composable +private fun Library(library: Library) { + val (name, source) = library + Text( + buildAnnotatedString { + append(name) + append(" (") + withLink( + LinkAnnotation.Url( + source.url, + TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.Underline, + ), + ) + ) + ) { + append(source.name ?: source.url) + } + append(")") + } + ) +} + +private data class LibrarySource(val url: String, val name: String? = null) + +private data class Library( + val text: String, + val source: LibrarySource, +) { + constructor(text: String, url: String) : this(text, LibrarySource(url)) +} + +private val UsedLibraries: List<Library> = listOf( + Library("zlib", "https://www.zlib.net"), + Library("OpenSSL", "https://www.openssl.org"), + Library("libcurl", "https://curl.haxx.se/libcurl"), + Library("imgui", "https://github.com/ocornut/imgui"), + Library("fontawesome", "https://github.com/FortAwesome/Font-Awesome"), + Library("boost", "https://www.boost.org"), + Library("libusb", "https://libusb.info"), + Library( + "Modified ih264 from Android project", + LibrarySource( + "https://github.com/cemu-project/Cemu/tree/main/dependencies/ih264d", + "Source" + ) + ) +) diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/android/contentresolver/ContentResolverExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/android/contentresolver/ContentResolverExtensions.kt new file mode 100644 index 00000000..edacfe6b --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/android/contentresolver/ContentResolverExtensions.kt @@ -0,0 +1,56 @@ +package info.cemu.cemu.common.android.contentresolver + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import kotlinx.coroutines.yield + +sealed class DocumentEntry { + data class File(val uri: Uri, val size: Long) : DocumentEntry() + data class Directory(val uri: Uri) : DocumentEntry() +} + +suspend fun ContentResolver.walkDocumentTree( + dirUri: Uri, + onEntry: (DocumentEntry) -> Unit, +) { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + dirUri, + DocumentsContract.getDocumentId(dirUri) + ) + + val cursor = 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) + onEntry(DocumentEntry.File(documentUri, sizeInBytes)) + continue + } + + onEntry(DocumentEntry.Directory(documentUri)) + + walkDocumentTree( + DocumentsContract.buildDocumentUriUsingTree(dirUri, documentId), + onEntry, + ) + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/android/context/ContextExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/android/context/ContextExtensions.kt new file mode 100644 index 00000000..855b92e0 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/android/context/ContextExtensions.kt @@ -0,0 +1,12 @@ +package info.cemu.cemu.common.android.context + +import android.content.Context +import java.io.File + +fun Context.internalFolder(): File { + val externalFilesDir = getExternalFilesDir(null) + if (externalFilesDir != null) { + return externalFilesDir + } + return filesDir +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/android/inputdevice/InputDeviceExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/android/inputdevice/InputDeviceExtensions.kt new file mode 100644 index 00000000..a7faf6c6 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/android/inputdevice/InputDeviceExtensions.kt @@ -0,0 +1,11 @@ +package info.cemu.cemu.common.android.inputdevice + +import android.view.InputDevice + +fun InputDevice.isGameController(): Boolean { + return !isVirtual && ( + sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD + || sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + || sources and InputDevice.SOURCE_DPAD == InputDevice.SOURCE_DPAD + ) +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/android/motionevent/MotionEventExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/android/motionevent/MotionEventExtensions.kt new file mode 100644 index 00000000..df2abbea --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/android/motionevent/MotionEventExtensions.kt @@ -0,0 +1,9 @@ +package info.cemu.cemu.common.android.motionevent + +import android.view.InputDevice +import android.view.MotionEvent + +fun MotionEvent.isMotionEventFromJoystickOrGamepad(): Boolean { + return (source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + || (source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/collections/CollectionExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/collections/CollectionExtensions.kt new file mode 100644 index 00000000..0172ae17 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/collections/CollectionExtensions.kt @@ -0,0 +1,6 @@ +package info.cemu.cemu.common.collections + + +fun <T> Set<T>.toggleInSet(item: T): Set<T> { + return if (item in this) this - item else this + item +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/io/IOUtils.kt b/src/android/app/src/main/java/info/cemu/cemu/common/io/IOUtils.kt new file mode 100644 index 00000000..654f8911 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/io/IOUtils.kt @@ -0,0 +1,13 @@ +package info.cemu.cemu.common.io + +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/common/io/JsonUtils.kt b/src/android/app/src/main/java/info/cemu/cemu/common/io/JsonUtils.kt new file mode 100644 index 00000000..7bacaa3e --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/io/JsonUtils.kt @@ -0,0 +1,17 @@ +package info.cemu.cemu.common.io + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.File + +@OptIn(ExperimentalSerializationApi::class) +inline fun <reified T> decodeJsonFromFile(file: File): T? { + return try { + file.inputStream().use { + Json.decodeFromStream<T>(it) + } + } catch (exception: Exception) { + null + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/io/ZipUtils.kt b/src/android/app/src/main/java/info/cemu/cemu/common/io/ZipUtils.kt new file mode 100644 index 00000000..cb304f29 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/io/ZipUtils.kt @@ -0,0 +1,42 @@ +package info.cemu.cemu.common.io + +import java.io.FileOutputStream +import java.io.InputStream +import java.nio.file.Path +import java.nio.file.Paths +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +fun unzip(stream: InputStream, targetDir: String) { + ZipInputStream(stream).use { zipInputStream -> + val buffer = ByteArray(8192) + + var zipEntry: ZipEntry? = zipInputStream.nextEntry + + while (zipEntry != null) { + extractZipEntry(zipInputStream, zipEntry, buffer, targetDir) + zipEntry = zipInputStream.nextEntry + } + } +} + +fun unzip(stream: InputStream, targetDir: Path) = unzip(stream, targetDir.toString()) + +private fun extractZipEntry( + zipInputStream: ZipInputStream, + zipEntry: ZipEntry, + buffer: ByteArray, + targetDir: String, +) { + val file = Paths.get(targetDir, zipEntry.name).toFile() + if (zipEntry.isDirectory) { + file.apply { if (!isDirectory) mkdirs() } + return + } + FileOutputStream(file).use { fileOutputStream -> + var bytesRead: Int + while ((zipInputStream.read(buffer).also { bytesRead = it }) > 0) { + fileOutputStream.write(buffer, 0, bytesRead) + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/settings/PreferenceDelegate.kt b/src/android/app/src/main/java/info/cemu/cemu/common/settings/PreferenceDelegate.kt new file mode 100644 index 00000000..df0d39bf --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/settings/PreferenceDelegate.kt @@ -0,0 +1,71 @@ +package info.cemu.cemu.common.settings + +import android.content.SharedPreferences +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty +import androidx.core.content.edit + +class PreferenceDelegate<T>( + private val sharedPreferences: SharedPreferences, + private val defaultValue: T, + private val getter: SharedPreferences.(String, T) -> T, + private val setter: SharedPreferences.Editor.(String, T) -> Unit +) : ReadWriteProperty<Any, T> { + + private var cachedValue: T? = null + private var isCached = false + + override fun getValue(thisRef: Any, property: KProperty<*>): T { + val key = "${thisRef::class.simpleName}_${property.name}".uppercase() + + if (!isCached) { + cachedValue = sharedPreferences.getter(key, defaultValue) + isCached = true + } + + return cachedValue ?: defaultValue + } + + override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { + val key = "${thisRef::class.simpleName}_${property.name}".uppercase() + cachedValue = value + isCached = true + sharedPreferences.edit { setter(key, value) } + } +} + +inline fun <reified T : Enum<T>> SharedPreferences.enumPref(default: T) = + PreferenceDelegate( + this, + default, + { key, default -> + val enumOrdinal = getInt(key, default.ordinal) + enumValues<T>().firstOrNull { it.ordinal == enumOrdinal } ?: default + }, + { key, value -> + putInt(key, value.ordinal) + } + ) + +fun SharedPreferences.booleanPref(default: Boolean) = + PreferenceDelegate( + this, + default, + SharedPreferences::getBoolean, + SharedPreferences.Editor::putBoolean + ) + +fun SharedPreferences.stringPref(default: String) = + PreferenceDelegate( + this, default, + { key, def -> getString(key, def) ?: def }, + SharedPreferences.Editor::putString + ) + +fun SharedPreferences.intPref(default: Int) = + PreferenceDelegate( + this, + default, + SharedPreferences::getInt, + SharedPreferences.Editor::putInt + ) \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/settings/SettingsManager.kt b/src/android/app/src/main/java/info/cemu/cemu/common/settings/SettingsManager.kt new file mode 100644 index 00000000..c8cc1aa6 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/settings/SettingsManager.kt @@ -0,0 +1,48 @@ +package info.cemu.cemu.common.settings + +import android.content.Context +import android.content.SharedPreferences +import info.cemu.cemu.common.ui.localization.DEFAULT_LANGUAGE +import kotlin.getValue + +enum class GamePadPosition { + ABOVE, + BELOW, + LEFT, + RIGHT; + + fun isVertical() = this == ABOVE || this == BELOW + fun appearsAfterTV() = this == BELOW || this == RIGHT +} + +class EmulationSettings(sharedPreferences: SharedPreferences) { + var gamePadPosition by sharedPreferences.enumPref(GamePadPosition.RIGHT) +} + +class GuiSettings(sharedPreferences: SharedPreferences) { + var language by sharedPreferences.stringPref(DEFAULT_LANGUAGE) +} + +class InputOverlaySettings(sharedPreferences: SharedPreferences) { + var isVibrateOnTouchEnabled by sharedPreferences.booleanPref(false) + var isOverlayEnabled by sharedPreferences.booleanPref(false) + var controllerIndex by sharedPreferences.intPref(0) + var alpha by sharedPreferences.intPref(64) +} + +object SettingsManager { + fun initialize(context: Context) { + sharedPreferences = + context.getSharedPreferences(SETTINGS_NAME, Context.MODE_PRIVATE) + } + + private lateinit var sharedPreferences: SharedPreferences + + val emulationSettings by lazy { EmulationSettings(sharedPreferences) } + + val guiSettings by lazy { GuiSettings(sharedPreferences) } + + val inputOverlaySettings by lazy { InputOverlaySettings(sharedPreferences) } + + private const val SETTINGS_NAME = "SETTINGS" +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/string/StringExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/string/StringExtensions.kt new file mode 100644 index 00000000..78facc1a --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/string/StringExtensions.kt @@ -0,0 +1,17 @@ +package info.cemu.cemu.common.string + +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://") + +fun String.parseHexOrNull(): UInt? { + return try { + toUInt(16) + } catch (_: NumberFormatException) { + null + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ActivityContent.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ActivityContent.kt new file mode 100644 index 00000000..9ade7d83 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ActivityContent.kt @@ -0,0 +1,20 @@ +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import info.cemu.cemu.common.ui.theme.CemuTheme + +@Composable +fun ActivityContent(content: @Composable () -> Unit) { + CemuTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + content() + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Button.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Button.kt new file mode 100644 index 00000000..cd2cc30d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Button.kt @@ -0,0 +1,50 @@ +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Button(label: String, description: String? = null, onClick: () -> Unit = {}) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Text( + modifier = Modifier, + text = label, + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + ) + if (description != null) { + Text( + modifier = Modifier.padding(top = 8.dp), + color = LocalContentColor.current.copy(alpha = 0.6f), + text = description, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ComposeExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ComposeExtensions.kt new file mode 100644 index 00000000..5c895954 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ComposeExtensions.kt @@ -0,0 +1,13 @@ +package info.cemu.cemu.common.ui.components + +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/common/ui/components/DrawerLayout.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/DrawerLayout.kt new file mode 100644 index 00000000..d8109bb9 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/DrawerLayout.kt @@ -0,0 +1,41 @@ +package info.cemu.cemu.common.ui.components + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.drawerlayout.widget.DrawerLayout as AndroidxDrawerLayout + +class DrawerLayout @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, +) : AndroidxDrawerLayout(context, attrs) { + private var isLocked = false + private val lockedModeDrawerListener = object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) {} + + override fun onDrawerOpened(drawerView: View) { + setDrawerLockMode(LOCK_MODE_UNLOCKED) + } + + override fun onDrawerClosed(drawerView: View) { + setDrawerLockMode(LOCK_MODE_LOCKED_CLOSED) + } + + override fun onDrawerStateChanged(newState: Int) {} + } + + fun setLockedMode(isLocked: Boolean) { + if (this.isLocked == isLocked) { + return + } + + this.isLocked = isLocked + if (isLocked) { + setDrawerLockMode(LOCK_MODE_LOCKED_CLOSED) + addDrawerListener(lockedModeDrawerListener) + return + } + + setDrawerLockMode(LOCK_MODE_UNLOCKED) + removeDrawerListener(lockedModeDrawerListener) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/FilledSearchToolbar.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/FilledSearchToolbar.kt new file mode 100644 index 00000000..b0329c80 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/FilledSearchToolbar.kt @@ -0,0 +1,190 @@ +package info.cemu.cemu.common.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.PlatformImeOptions +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilledSearchToolbar( + query: String, + hint: String, + onValueChange: (String) -> Unit, + actions: @Composable RowScope.() -> Unit = {}, +) { + var searchBarActive by remember { mutableStateOf(false) } + + BackHandler(enabled = searchBarActive) { searchBarActive = false } + + TopAppBar( + actions = actions, + title = { + Card( + onClick = { searchBarActive = true }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(8.dp), + shape = RoundedCornerShape(24.dp), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(8.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (!searchBarActive) { + SearchToolbarHint(hint) + } else { + SearchToolbarInput( + trailingIcon = { + IconButton( + onClick = { + onValueChange("") + searchBarActive = false + }, + modifier = Modifier + .padding(horizontal = 8.dp) + .size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + value = query, + hint = hint, + onValueChange = onValueChange, + ) + } + } + } + }, + ) +} + +@Composable +fun SearchToolbarInput( + value: String, + hint: String, + trailingIcon: @Composable () -> Unit = {}, + onValueChange: (String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + trailingIcon() + BasicTextField( + modifier = Modifier + .fillMaxHeight() + .focusRequester(focusRequester) + .weight(1.0f), + cursorBrush = SolidColor(LocalContentColor.current), + value = value, + keyboardOptions = KeyboardOptions( + platformImeOptions = PlatformImeOptions("flagNoFullscreen|flagNoExtractUi") + ), + singleLine = true, + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Start, + color = LocalContentColor.current, + ), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + SearchToolbarHint( + hint = hint, + color = LocalContentColor.current.copy(alpha = 0.6f) + ) + } + innerTextField() + }, + onValueChange = onValueChange, + ) + if (value.isNotEmpty()) { + IconButton( + onClick = { onValueChange("") }, + modifier = Modifier + .padding(horizontal = 8.dp) + .size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = null + ) + } + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +@Composable +fun SearchToolbarHint( + hint: String, + color: Color = LocalContentColor.current, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(24.dp), + imageVector = Icons.Default.Search, + tint = color, + contentDescription = null + ) + Text( + text = hint, + fontSize = 16.sp, + color = color, + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Header.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Header.kt new file mode 100644 index 00000000..5e0bd7ae --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Header.kt @@ -0,0 +1,19 @@ +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Header(text: String?, modifier: Modifier = Modifier) { + Text( + modifier = modifier.padding(horizontal = 8.dp, vertical = 16.dp), + text = text ?: "", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ScreenContent.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ScreenContent.kt new file mode 100644 index 00000000..14f0f359 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/ScreenContent.kt @@ -0,0 +1,153 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.dropUnlessResumed + +@Composable +fun ScreenContent( + appBarText: String, + snackbarHost: @Composable () -> Unit = {}, + navigateBack: () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + contentModifier: Modifier = Modifier.padding(8.dp), + contentVerticalArrangement: Arrangement.Vertical = Arrangement.Top, + contentHorizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit, +) { + ScreenContentGeneric( + appBarTitle = { DefaultAppBarTitle(appBarText) }, + snackbarHost = snackbarHost, + navigateBack = navigateBack, + actions = actions, + ) { + Column( + verticalArrangement = contentVerticalArrangement, + horizontalAlignment = contentHorizontalAlignment, + modifier = contentModifier.verticalScroll(rememberScrollState()), + ) { + content() + } + } +} + +@Composable +fun DefaultAppBarTitle(appBarText: String) { + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = appBarText, + fontSize = 18.sp, + ) +} + +@Composable +fun ScreenContentLazy( + appBarText: String, + navigateBack: () -> Unit, + snackbarHost: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + contentModifier: Modifier = Modifier.padding(8.dp), + contentVerticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: LazyListScope.() -> Unit, +) { + ScreenContentGeneric( + snackbarHost = snackbarHost, + appBarTitle = { DefaultAppBarTitle(appBarText) }, + navigateBack = navigateBack, + actions = actions, + ) { + LazyColumn( + verticalArrangement = contentVerticalArrangement, + modifier = contentModifier, + ) { + content() + } + } +} + +@Composable +fun ScreenContentLazy( + appBarTitle: @Composable () -> Unit, + navigateBack: () -> Unit, + snackbarHost: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + contentModifier: Modifier = Modifier.padding(8.dp), + contentVerticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: LazyListScope.() -> Unit, +) { + ScreenContentGeneric( + snackbarHost = snackbarHost, + appBarTitle = appBarTitle, + navigateBack = navigateBack, + actions = actions, + ) { + LazyColumn( + verticalArrangement = contentVerticalArrangement, + modifier = contentModifier, + ) { + content() + } + } +} + +@Composable +private fun ScreenContentGeneric( + navigateBack: () -> Unit, + appBarTitle: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable () -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = snackbarHost, + topBar = { + TopAppBar( + actions = actions, + title = appBarTitle, + navigationIcon = { + IconButton(onClick = dropUnlessResumed { navigateBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + ) + }, + ) { scaffoldPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(scaffoldPadding) + ) { + content() + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/SingleSelection.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/SingleSelection.kt new file mode 100644 index 00000000..bc084307 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/SingleSelection.kt @@ -0,0 +1,229 @@ +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import info.cemu.cemu.common.ui.localization.tr + +@Composable +fun SingleSelection( + label: String, + choice: String, + choices: Collection<String>, + isChoiceEnabled: (String) -> Boolean = { true }, + enabled: Boolean = true, + modifier: Modifier = Modifier.fillMaxWidth(), + onChoiceChanged: (String) -> Unit, +) { + SingleSelection( + label = label, + choice = choice, + choices = choices, + modifier = modifier, + choiceToString = { it }, + enabled = enabled, + isChoiceEnabled = isChoiceEnabled, + onChoiceChanged = onChoiceChanged, + ) +} + + +@Composable +fun <T> SingleSelection( + label: String, + initialChoice: () -> T, + choices: Collection<T>, + modifier: Modifier = Modifier.fillMaxWidth(), + choiceToString: @Composable (T) -> String, + isChoiceEnabled: (T) -> Boolean = { true }, + enabled: Boolean = true, + onChoiceChanged: (T) -> Unit, +) { + var choice by rememberSaveable { mutableStateOf(initialChoice()) } + SingleSelection( + label = label, + choice = choice, + choices = choices, + modifier = modifier, + isChoiceEnabled = isChoiceEnabled, + enabled = enabled, + choiceToString = choiceToString, + onChoiceChanged = { newChoice -> + choice = newChoice + onChoiceChanged(newChoice) + }, + ) +} + +@Composable +fun <T> SingleSelection( + label: String, + choice: T, + choices: Collection<T>, + choiceToString: @Composable (T) -> String, + modifier: Modifier = Modifier.fillMaxWidth(), + enabled: Boolean = true, + isChoiceEnabled: (T) -> Boolean = { true }, + onChoiceChanged: (T) -> Unit, +) { + var showSelectDialog by rememberSaveable { mutableStateOf(false) } + + val clickableModifier = if (enabled) { + Modifier.clickable { showSelectDialog = true } + } else { + Modifier + } + CompositionLocalProvider( + LocalContentColor provides + MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.38f) + ) + { + Column( + modifier = modifier + .then(clickableModifier) + .padding(8.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = label, + modifier = Modifier.padding(vertical = 8.dp), + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + ) + Text( + text = choiceToString(choice), + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + ) + } + } + + if (showSelectDialog) { + SelectDialog( + label = label, + currentChoice = choice, + choices = choices, + choiceToString = choiceToString, + onDismissRequest = { showSelectDialog = false }, + isChoiceEnabled = isChoiceEnabled, + onChoiceChanged = onChoiceChanged + ) + } +} + +@Composable +private fun <T> SelectDialog( + label: String, + currentChoice: T, + choices: Collection<T>, + isChoiceEnabled: (T) -> Boolean, + choiceToString: @Composable (T) -> String, + onDismissRequest: () -> Unit, + onChoiceChanged: (T) -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + modifier = Modifier + .sizeIn(maxWidth = 560.dp, maxHeight = 560.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 8.dp + ), + text = label, + fontSize = 24.sp, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + .weight(weight = 1.0f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + choices.forEach { choice -> + Choice( + label = choiceToString(choice), + selected = currentChoice == choice, + isEnabled = isChoiceEnabled(choice), + onClick = { + onChoiceChanged(choice) + onDismissRequest() + }, + ) + } + } + + HorizontalDivider() + + TextButton( + onClick = onDismissRequest, + modifier = Modifier + .padding(8.dp) + .align(Alignment.End), + ) { + Text(tr("Cancel")) + } + } + } +} + +@Composable +fun Choice(label: String, selected: Boolean, isEnabled: Boolean, onClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .let { + if (isEnabled) it.clickable { onClick() } + else it + } + .padding(vertical = 16.dp, horizontal = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + enabled = isEnabled, + selected = selected, + onClick = null, + ) + Text( + text = label, + color = if (isEnabled) LocalContentColor.current + else LocalContentColor.current.copy(alpha = 0.6f) + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Slider.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Slider.kt new file mode 100644 index 00000000..6c5e3db8 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Slider.kt @@ -0,0 +1,51 @@ +package info.cemu.cemu.common.ui.components + +import androidx.annotation.IntRange +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.material3.Slider as MaterialSlider + +@Composable +fun Slider( + label: String, + initialValue: () -> Int, + valueFrom: Int, + valueTo: Int, + @IntRange(from = 0) steps: Int = 0, + labelFormatter: (Int) -> String, + onValueChange: (Int) -> Unit, +) { + var value by rememberSaveable { mutableFloatStateOf(initialValue().toFloat()) } + Column(modifier = Modifier.padding(8.dp)) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = label, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = labelFormatter(value.fastRoundToInt()), + fontWeight = FontWeight.Light, + fontSize = 14.sp, + ) + MaterialSlider( + valueRange = valueFrom.toFloat()..valueTo.toFloat(), + steps = steps, + value = value, + onValueChangeFinished = { onValueChange(value.toInt()) }, + onValueChange = { value = it }, + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Toggle.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Toggle.kt new file mode 100644 index 00000000..0187fc72 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/components/Toggle.kt @@ -0,0 +1,65 @@ +package info.cemu.cemu.common.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Toggle( + label: String, + initialCheckedState: () -> Boolean, + onCheckedChanged: (Boolean) -> Unit, + description: String?, +) { + var checked by rememberSaveable { mutableStateOf(initialCheckedState()) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + checked = !checked + onCheckedChanged(checked) + }, + ) + { + Column( + modifier = Modifier + .weight(1.0f) + .padding(8.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = label, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + ) + if (description != null) { + Text( + text = description, + fontSize = 14.sp, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + Switch( + modifier = Modifier.padding(end = 8.dp, top = 8.dp, bottom = 8.dp), + checked = checked, + onCheckedChange = null, + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/NativeTranslationHelpers.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/NativeTranslationHelpers.kt new file mode 100644 index 00000000..72e9375f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/NativeTranslationHelpers.kt @@ -0,0 +1,25 @@ +package info.cemu.cemu.common.ui.localization + +import info.cemu.cemu.nativeinterface.NativeGameTitles.ConsoleRegion +import info.cemu.cemu.nativeinterface.NativeInput.EmulatedControllerType + +fun regionToString(region: Int): String = when (region) { + ConsoleRegion.JPN -> tr("Japan") + ConsoleRegion.USA -> tr("USA") + ConsoleRegion.EUR -> tr("Europe") + ConsoleRegion.AUS_DEPR -> tr("Australia") + ConsoleRegion.CHN -> tr("China") + ConsoleRegion.KOR -> tr("Korea") + ConsoleRegion.TWN -> tr("Taiwan") + ConsoleRegion.AUTO -> tr("Auto") + else -> tr("Many") +} + +fun controllerTypeToString(type: Int) = when (type) { + EmulatedControllerType.DISABLED -> tr("Disabled") + EmulatedControllerType.VPAD -> tr("Wii U GamePad") + EmulatedControllerType.PRO -> tr("Wii U Pro Controller") + EmulatedControllerType.WIIMOTE -> tr("Wiimote") + EmulatedControllerType.CLASSIC -> tr("Wii U Classic Controller") + else -> throw IllegalArgumentException("Invalid controller type: $type") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/PoFile.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/PoFile.kt new file mode 100644 index 00000000..ed2e9e3b --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/PoFile.kt @@ -0,0 +1,63 @@ +package info.cemu.cemu.common.ui.localization + +import java.io.InputStream + +private enum class PoKey { MSG_ID, MSG_STR, NONE } + +fun parsePoFile(source: InputStream): Map<String, String> { + val reader = source.bufferedReader() + var key = PoKey.NONE + var msgId = "" + var msg = "" + val strings = mutableMapOf<String, String>() + + while (true) { + val line = reader.readLine() ?: break + + when { + line.isBlank() -> { + if (msgId.isEmpty() || msg.isEmpty()) { + continue + } + + strings[msgId] = msg + + key = PoKey.NONE + msgId = "" + msg = "" + } + + line.startsWith("msgid ") -> { + key = PoKey.MSG_ID + msgId = line.substringAfter("msgid ").unescape() + } + + line.startsWith("msgstr ") -> { + key = PoKey.MSG_STR + msg = line.substringAfter("msgstr ").unescape() + } + + // Ignore entry if it has plural forms or context, the core code doesn't use them. + line.startsWith("msgid_plural ") || line.startsWith("msgctxt ") -> { + key = PoKey.NONE + msg = "" + msgId = "" + } + + else -> when (key) { + PoKey.MSG_ID -> msgId += line.unescape() + PoKey.MSG_STR -> msg += line.unescape() + PoKey.NONE -> {} + } + } + } + + return strings +} + +private fun String.unescape(): String { + return trim() + .trim('"') + .replace("\\\"", "\"") + .replace("\\n", "\n") +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/Translation.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/Translation.kt new file mode 100644 index 00000000..0d345cc2 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/localization/Translation.kt @@ -0,0 +1,94 @@ +package info.cemu.cemu.common.ui.localization + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import info.cemu.cemu.nativeinterface.NativeLocalization +import name.kropp.kotlinx.gettext.Gettext +import name.kropp.kotlinx.gettext.Locale +import name.kropp.kotlinx.gettext.load +import java.io.IOException +import java.text.MessageFormat + +private var I18n = Gettext.Fallback + +private abstract class Translation(val locale: Locale) { + abstract fun load(context: Context) +} + +private class AssetTranslation(locale: Locale, private val assetFile: String) : + Translation(locale) { + override fun load(context: Context) { + try { + val poFileContent = context.assets.open(assetFile).use { source -> source.readBytes() } + I18n = Gettext.load(locale, poFileContent.inputStream()) + NativeLocalization.setTranslations(parsePoFile(poFileContent.inputStream())) + } catch (_: IOException) { + Log.e("CemuTranslations", "failed to load translations from asset: $assetFile") + return + } + } +} + +private const val TRANSLATIONS_FOLDER = "translations" +private const val TRANSLATIONS_FILE_NAME = "cemu.po" +const val DEFAULT_LANGUAGE = "en" + +private val DefaultTranslation = object : Translation(Locale(DEFAULT_LANGUAGE)) { + override fun load(context: Context) { + I18n = Gettext.Fallback + NativeLocalization.setTranslations(emptyMap()) + } +} + +private var Translations = listOf<Translation>(DefaultTranslation) + +data class Language(val code: String, val displayName: String) + +fun getAvailableLanguages() = + Translations.map { Language(it.locale.language, it.locale.getDisplayName(it.locale)) } + +fun setLanguage(languageCode: String, context: Context) { + val translation = Translations.firstOrNull { it.locale.language == languageCode } ?: return + translation.load(context) + CurrentLanguage = translation.locale.language +} + +fun getCurrentLocale() = I18n.locale + +fun setTranslations(context: Context) { + val assetTranslations = context.assets.list(TRANSLATIONS_FOLDER)?.filter { language -> + if (language == DEFAULT_LANGUAGE) return@filter false + + context.assets.list("$TRANSLATIONS_FOLDER/$language") + ?.contains(TRANSLATIONS_FILE_NAME) + ?: false + }?.map { language -> + val locale = Locale(language) + val assetFile = "$TRANSLATIONS_FOLDER/$language/$TRANSLATIONS_FILE_NAME" + AssetTranslation(locale, assetFile) + } ?: listOf() + + Translations = listOf(DefaultTranslation).plus(assetTranslations) +} + +fun trNoop(text: String) = text + +fun tr(text: String) = I18n.tr(text) + +fun tr(text: String, vararg args: Any): String = MessageFormat.format(I18n.tr(text), *args) + +private var CurrentLanguage by mutableStateOf(DEFAULT_LANGUAGE) +private val LocalAppLanguage = staticCompositionLocalOf { DEFAULT_LANGUAGE } + +@Composable +fun TranslatableContent(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalAppLanguage provides CurrentLanguage) { + content() + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/common/ui/theme/Theme.kt b/src/android/app/src/main/java/info/cemu/cemu/common/ui/theme/Theme.kt new file mode 100644 index 00000000..bb5e3c39 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/common/ui/theme/Theme.kt @@ -0,0 +1,36 @@ +package info.cemu.cemu.common.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme() + +private val LightColorScheme = lightColorScheme() + +@Composable +fun CemuTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/CanvasOnTouchListener.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/CanvasOnTouchListener.kt new file mode 100644 index 00000000..16545cec --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/CanvasOnTouchListener.kt @@ -0,0 +1,39 @@ +package info.cemu.cemu.emulation + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import info.cemu.cemu.nativeinterface.NativeInput + +class CanvasOnTouchListener(val isTV: Boolean) : View.OnTouchListener { + private var currentPointerId: Int = -1 + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val pointerId = event.getPointerId(pointerIndex) + if (currentPointerId != -1 && pointerId != currentPointerId) { + return false + } + val x = event.getX(pointerIndex).toInt() + val y = event.getY(pointerIndex).toInt() + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + NativeInput.onTouchDown(x, y, isTV) + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + currentPointerId = -1 + NativeInput.onTouchUp(x, y, isTV) + return true + } + + MotionEvent.ACTION_MOVE -> { + NativeInput.onTouchMove(x, y, isTV) + return true + } + } + return false + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationActivity.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationActivity.kt new file mode 100644 index 00000000..8e6b5b68 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationActivity.kt @@ -0,0 +1,478 @@ +package info.cemu.cemu.emulation + +import android.annotation.SuppressLint +import android.graphics.SurfaceTexture +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.annotation.Keep +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import info.cemu.cemu.BuildConfig +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.databinding.ActivityEmulationBinding +import info.cemu.cemu.databinding.LayoutSideMenuCheckboxItemBinding +import info.cemu.cemu.databinding.LayoutSideMenuEmulationBinding +import info.cemu.cemu.databinding.LayoutSideMenuTextItemBinding +import info.cemu.cemu.nativeinterface.NativeEmulation +import info.cemu.cemu.nativeinterface.NativeException +import info.cemu.cemu.common.settings.EmulationSettings +import info.cemu.cemu.common.settings.GamePadPosition +import info.cemu.cemu.common.settings.InputOverlaySettings +import info.cemu.cemu.common.settings.SettingsManager +import info.cemu.cemu.emulation.inputoverlay.InputOverlaySurfaceView +import java.lang.ref.WeakReference +import kotlin.system.exitProcess + +@SuppressLint("ClickableViewAccessibility") +class EmulationActivity : AppCompatActivity() { + private inner class CanvasSurfaceHolderCallback(val isMainCanvas: Boolean) : + SurfaceHolder.Callback { + var surfaceSet: Boolean = false + + override fun surfaceCreated(surfaceHolder: SurfaceHolder) {} + + override fun surfaceChanged( + surfaceHolder: SurfaceHolder, + format: Int, + width: Int, + height: Int, + ) { + try { + NativeEmulation.setSurfaceSize(width, height, isMainCanvas) + if (surfaceSet) { + return + } + NativeEmulation.setSurface(surfaceHolder.surface, isMainCanvas) + surfaceSet = true + } catch (exception: NativeException) { + onEmulationError(tr("Failed creating surface: {0}", exception.message!!)) + } + } + + override fun surfaceDestroyed(surfaceHolder: SurfaceHolder) { + NativeEmulation.clearSurface(isMainCanvas) + surfaceSet = false + } + } + + private var emulationTextInputDialog: AlertDialog? = null + private var padCanvas: SurfaceView? = null + private lateinit var binding: ActivityEmulationBinding + private lateinit var inputOverlaySettings: InputOverlaySettings + private lateinit var emulationSettings: EmulationSettings + private lateinit var inputOverlaySurfaceView: InputOverlaySurfaceView + private lateinit var sensorManager: SensorManager + private var toast: Toast? = null + + private var isGameRunning = false + private var isMotionEnabled = false + private var isDrawerLocked = false + + private var hasEmulationError = false + + override fun onGenericMotionEvent(event: MotionEvent): Boolean { + if (InputHandler.onMotionEvent(event)) { + return true + } + + return super.onGenericMotionEvent(event) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (InputHandler.onKeyEvent(event)) { + return true + } + + return super.dispatchKeyEvent(event) + } + + private fun getLaunchPath(): String { + val extras = intent.extras + val data = intent.data + var launchPath: String? = null + + if (extras != null) { + launchPath = extras.getString(EXTRA_LAUNCH_PATH) + } + + if (launchPath == null && data != null) { + launchPath = data.toString() + } + + if (launchPath == null) { + throw RuntimeException("launchPath is null") + } + + return launchPath + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + emulationActivityInstance = WeakReference(this) + + inputOverlaySettings = SettingsManager.inputOverlaySettings + emulationSettings = SettingsManager.emulationSettings + sensorManager = SensorManager(this) + sensorManager.setDeviceRotationProvider(deviceRotationProvider = { display.rotation }) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = toggleDrawer() + }) + + initializeView(getLaunchPath()) + + setContentView(binding.root) + } + + private fun toggleDrawer() { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } else { + binding.drawerLayout.open() + } + } + + private fun destroyPadCanvas() { + if (padCanvas == null) { + return + } + binding.canvasesLayout.removeView(padCanvas) + padCanvas = null + } + + private fun setPadViewVisibility(visible: Boolean) { + if (visible) { + createPadCanvas() + } else { + destroyPadCanvas() + } + } + + private fun setMotionEnabled(enabled: Boolean) { + isMotionEnabled = enabled + if (isMotionEnabled) { + sensorManager.startListening() + } else { + sensorManager.pauseListening() + } + } + + private fun LayoutSideMenuTextItemBinding.setEnabled(isEnabled: Boolean) { + textItem.isEnabled = isEnabled + textItem.alpha = if (isEnabled) 1f else 0.7f + } + + private fun LayoutSideMenuTextItemBinding.configure( + label: String, + isEnabled: Boolean = true, + onClick: () -> Unit, + ) { + setEnabled(isEnabled) + this.label = label + textItem.setOnClickListener { + onClick() + binding.drawerLayout.close() + } + } + + private fun LayoutSideMenuCheckboxItemBinding.configure( + label: String, + initialCheckedStatus: Boolean = false, + onCheckChanged: (Boolean) -> Unit, + ) { + this.label = label + checkbox.isChecked = initialCheckedStatus + checkboxItem.setOnClickListener { + checkbox.isChecked = !checkbox.isChecked + onCheckChanged(checkbox.isChecked) + binding.drawerLayout.close() + } + } + + private fun LayoutSideMenuEmulationBinding.configureSideMenu() { + val isInputOverlayEnabled = inputOverlaySettings.isOverlayEnabled + + enableMotionCheckbox.configure( + label = tr("Enable motion"), + onCheckChanged = ::setMotionEnabled + ) + + lockDrawerCheckbox.configure( + tr("Lock drawer"), + onCheckChanged = { + isDrawerLocked = !isDrawerLocked + binding.drawerLayout.setLockedMode(isDrawerLocked) + } + ) + + replaceTvWithPadCheckbox.configure( + label = tr("Replace TV with PAD"), + onCheckChanged = NativeEmulation::setReplaceTVWithPadView + ) + + showPadCheckbox.configure( + label = tr(text = "Show PAD"), + onCheckChanged = ::setPadViewVisibility + ) + + showInputOverlayCheckbox.configure( + tr(text = "Show input overlay"), + initialCheckedStatus = isInputOverlayEnabled, + onCheckChanged = { showInputOverlay -> + editInputsMenuItem.setEnabled(showInputOverlay) + resetInputOverlayMenuItem.setEnabled(showInputOverlay) + inputOverlaySurfaceView.setVisible(showInputOverlay) + } + ) + + editInputsMenuItem.configure( + label = tr("Edit inputs"), + isEnabled = isInputOverlayEnabled, + onClick = { + binding.editInputsLayout.visibility = View.VISIBLE + binding.finishEditInputsButton.visibility = View.VISIBLE + binding.moveInputsButton.performClick() + }) + + resetInputOverlayMenuItem.configure( + tr(text = "Reset input overlay"), + isEnabled = isInputOverlayEnabled, + onClick = inputOverlaySurfaceView::resetInputs + ) + + exitMenuItem.configure(tr("Exit"), onClick = ::showExitConfirmationDialog) + } + + private fun initializeView(launchPath: String) { + setFullscreen() + + binding = ActivityEmulationBinding.inflate(layoutInflater) + + initializeInputOverlay() + + binding.sideMenu.configureSideMenu() + + binding.moveInputsButton.setOnClickListener { _ -> + if (inputOverlaySurfaceView.getInputMode() == InputOverlaySurfaceView.InputMode.EDIT_POSITION) { + return@setOnClickListener + } + binding.resizeInputsButton.alpha = 0.5f + binding.moveInputsButton.alpha = 1.0f + toastMessage(tr("Edit input positions")) + inputOverlaySurfaceView.setInputMode(InputOverlaySurfaceView.InputMode.EDIT_POSITION) + } + binding.resizeInputsButton.setOnClickListener { _ -> + if (inputOverlaySurfaceView.getInputMode() == InputOverlaySurfaceView.InputMode.EDIT_SIZE) { + return@setOnClickListener + } + binding.moveInputsButton.alpha = 0.5f + binding.resizeInputsButton.alpha = 1.0f + toastMessage(tr("Edit input size")) + inputOverlaySurfaceView.setInputMode(InputOverlaySurfaceView.InputMode.EDIT_SIZE) + } + binding.finishEditInputsButton.text = tr("Done") + binding.finishEditInputsButton.setOnClickListener { _ -> + inputOverlaySurfaceView.setInputMode(InputOverlaySurfaceView.InputMode.DEFAULT) + binding.finishEditInputsButton.visibility = View.GONE + binding.editInputsLayout.visibility = View.GONE + toastMessage(tr("Exited input edit mode")) + } + + try { + val testSurfaceTexture = SurfaceTexture(0) + val testSurface = Surface(testSurfaceTexture) + NativeEmulation.initializeRenderer(testSurface) + testSurface.release() + testSurfaceTexture.release() + } catch (exception: NativeException) { + onEmulationError(tr("Failed to initialize renderer: {0}", exception.message!!)) + return + } + + val mainCanvas = binding.mainCanvas + val mainCanvasHolder = mainCanvas.holder + mainCanvasHolder.addCallback(CanvasSurfaceHolderCallback(isMainCanvas = true)) + mainCanvasHolder.addCallback(object : SurfaceChangedListener() { + override fun surfaceChanged() { + if (hasEmulationError) { + return + } + if (!isGameRunning) { + isGameRunning = true + startGame(launchPath) + } + } + }) + mainCanvas.setOnTouchListener(CanvasOnTouchListener(isTV = true)) + } + + private fun initializeInputOverlay() { + inputOverlaySurfaceView = binding.inputOverlay + + inputOverlaySurfaceView.setVisible(inputOverlaySettings.isOverlayEnabled) + } + + private fun toastMessage(text: String) { + toast?.cancel() + toast = Toast.makeText(this, text, Toast.LENGTH_SHORT) + .also { it.show() } + } + + private fun startGame(launchPath: String) { + val result = NativeEmulation.startGame(launchPath) + + if (result == NativeEmulation.StartGameStatusCode.SUCCESSFUL) { + return + } + + val errorMessage = when (result) { + NativeEmulation.StartGameStatusCode.ERROR_GAME_BASE_FILES_NOT_FOUND -> tr("Unable to launch game because the base files were not found.") + NativeEmulation.StartGameStatusCode.ERROR_NO_DISC_KEY -> tr("Could not decrypt title. Make sure that keys.txt contains the correct disc key for this title.") + NativeEmulation.StartGameStatusCode.ERROR_NO_TITLE_TIK -> tr("Could not decrypt title because title.tik is missing.") + else -> tr("Unable to launch game\nPath: {0}", launchPath) + } + + onEmulationError(errorMessage) + } + + override fun onPause() { + super.onPause() + sensorManager.pauseListening() + } + + override fun onResume() { + super.onResume() + if (isMotionEnabled) { + sensorManager.startListening() + } + } + + + override fun onDestroy() { + super.onDestroy() + sensorManager.pauseListening() + } + + private fun createPadCanvas() { + if (padCanvas != null) { + return + } + val padCanvas = SurfaceView(this) + + val padCanvasViewIndex: Int + val canvasLayoutParams: ViewGroup.LayoutParams + val orientation: Int + + val position = emulationSettings.gamePadPosition + + if (position.isVertical()) { + orientation = LinearLayout.VERTICAL + canvasLayoutParams = + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.0f) + } else { + orientation = LinearLayout.HORIZONTAL + canvasLayoutParams = + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1.0f) + } + + padCanvasViewIndex = if (position.appearsAfterTV()) 1 else 0 + + binding.mainCanvas.layoutParams = canvasLayoutParams + binding.canvasesLayout.orientation = orientation + binding.canvasesLayout.addView( + padCanvas, + padCanvasViewIndex, + canvasLayoutParams + ) + padCanvas.holder.addCallback(CanvasSurfaceHolderCallback(false)) + padCanvas.setOnTouchListener(CanvasOnTouchListener(false)) + this.padCanvas = padCanvas + } + + private fun showExitConfirmationDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(tr("Exit confirmation")) + .setMessage(tr("Are you sure you want to exit?")) + .setPositiveButton(tr("Yes")) { _, _ -> quit() } + .setNegativeButton(tr("No")) { _, _ -> } + .show() + } + + private fun onEmulationError(emulationError: String?) { + MaterialAlertDialogBuilder(this) + .setTitle(tr("Error")) + .setMessage(emulationError) + .setNeutralButton(tr("Quit")) { _, _ -> } + .setOnDismissListener { _ -> quit() } + .show() + } + + private fun setFullscreen() { + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + private fun quit() { + finishAffinity() + exitProcess(0) + } + + companion object { + const val EXTRA_LAUNCH_PATH: String = BuildConfig.APPLICATION_ID + ".LaunchPath" + private var emulationActivityInstance: WeakReference<EmulationActivity?> = + WeakReference(null) + + /** + * This method is called by swkbd using JNI. + */ + @Keep + @JvmStatic + fun showEmulationTextInput(initialText: String?, maxLength: Int) { + val emulationActivity = emulationActivityInstance.get() ?: return + if (emulationActivity.emulationTextInputDialog != null) { + return + } + + emulationActivity.runOnUiThread { + emulationActivity.emulationTextInputDialog = showEmulationTextInputDialog( + initialText, + maxLength, + emulationActivity, + emulationActivity.layoutInflater + ) + } + } + + /** + * This method is called by swkbd using JNI. + */ + @Keep + @JvmStatic + fun hideEmulationTextInput() { + val emulationActivity = emulationActivityInstance.get() ?: return + val textInputDialog = emulationActivity.emulationTextInputDialog ?: return + emulationActivity.emulationTextInputDialog = null + emulationActivity.runOnUiThread { textInputDialog.dismiss() } + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationTextInputEditText.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationTextInputEditText.kt new file mode 100644 index 00000000..2e778e0a --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/EmulationTextInputEditText.kt @@ -0,0 +1,132 @@ +package info.cemu.cemu.emulation + +import android.content.Context +import android.content.DialogInterface +import android.text.Editable +import android.text.InputFilter +import android.text.InputFilter.LengthFilter +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AlertDialog +import com.google.android.material.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeSwkbd +import java.util.regex.Pattern + +class EmulationTextInputEditText @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.editTextStyle, +) : TextInputEditText(context, attrs, defStyleAttr) { + fun appendFilter(inputFilter: InputFilter) { + filters += inputFilter + } + + fun updateText(text: String?) { + val hasFocus = hasFocus() + if (hasFocus) { + clearFocus() + } + setText(text) + if (hasFocus) { + requestFocus() + } + } + + private var onTextChangedListener: ((CharSequence) -> Unit)? = null + + init { + hint = tr("Input text") + + appendFilter { source: CharSequence, _, _, _, _, _ -> + if (INPUT_PATTERN.matcher(source).matches()) null else "" + } + inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_NORMAL + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) { + if (!hasFocus()) { + return + } + NativeSwkbd.onTextChanged(text.toString()) + } + + override fun afterTextChanged(s: Editable) {} + }) + } + + override fun onEditorAction(actionCode: Int) { + if (actionCode == EditorInfo.IME_ACTION_DONE && !text.isNullOrEmpty()) { + onFinishedEdit() + } + super.onEditorAction(actionCode) + } + + fun onFinishedEdit() { + NativeSwkbd.onFinishedInputEdit() + } + + fun setOnTextChangedListener(onTextChangedListener: ((CharSequence) -> Unit)?) { + this.onTextChangedListener = onTextChangedListener + } + + companion object { + private val INPUT_PATTERN: Pattern = + Pattern.compile("^[\\da-zA-Z \\-/;:',.?!#\\[\\]$%^&*()_@\\\\<>+=]+$") + } +} + +fun showEmulationTextInputDialog( + initialText: String?, + maxLength: Int, + context: Context, + layoutInflater: LayoutInflater +): AlertDialog { + NativeSwkbd.setCurrentInputText(initialText) + + val inputEditTextLayout = + layoutInflater.inflate( + info.cemu.cemu.R.layout.layout_emulation_input, + null + ) + + val inputEditText = + inputEditTextLayout.requireViewById<EmulationTextInputEditText>(info.cemu.cemu.R.id.emulation_input_text) + + inputEditText.updateText(initialText) + + val dialog = MaterialAlertDialogBuilder(context) + .setView(inputEditTextLayout) + .setCancelable(false) + .setPositiveButton(tr("Done")) { _, _ -> } + .show() + + val doneButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE)!! + + doneButton.isEnabled = false + + doneButton.setOnClickListener { _ -> inputEditText.onFinishedEdit() } + + inputEditText.setOnTextChangedListener { + doneButton.isEnabled = it.isNotEmpty() + } + + val parentTextInputLayout = + inputEditTextLayout.requireViewById<TextInputLayout>(info.cemu.cemu.R.id.emulation_input_layout) + + if (maxLength > 0) { + parentTextInputLayout.isCounterEnabled = true + parentTextInputLayout.counterMaxLength = maxLength + inputEditText.appendFilter(LengthFilter(maxLength)) + } else { + parentTextInputLayout.isCounterEnabled = false + } + + return dialog +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/InputHandler.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/InputHandler.kt new file mode 100644 index 00000000..ec745496 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/InputHandler.kt @@ -0,0 +1,52 @@ +package info.cemu.cemu.emulation + +import android.view.KeyEvent +import android.view.MotionEvent +import info.cemu.cemu.common.android.inputdevice.isGameController +import info.cemu.cemu.common.android.motionevent.isMotionEventFromJoystickOrGamepad +import info.cemu.cemu.nativeinterface.NativeInput.onNativeAxis +import info.cemu.cemu.nativeinterface.NativeInput.onNativeKey + +private fun KeyEvent.isSpecialKey(): Boolean { + return keyCode == KeyEvent.KEYCODE_VOLUME_DOWN + || keyCode == KeyEvent.KEYCODE_VOLUME_UP + || keyCode == KeyEvent.KEYCODE_CAMERA + || keyCode == KeyEvent.KEYCODE_ZOOM_IN + || keyCode == KeyEvent.KEYCODE_ZOOM_OUT +} + +object InputHandler { + fun onKeyEvent(event: KeyEvent): Boolean { + if (event.isSpecialKey()) { + return false + } + if (event.deviceId < 0) { + return false + } + val device = event.device + if (!device.isGameController()) { + return false + } + onNativeKey( + device.descriptor, + device.name, + event.keyCode, + event.action == KeyEvent.ACTION_DOWN + ) + return true + } + + fun onMotionEvent(event: MotionEvent): Boolean { + if (!event.isMotionEventFromJoystickOrGamepad()) { + return false + } + val device = event.device + val actionPointerIndex = event.actionIndex + for (motionRange in device.motionRanges) { + val axisValue = event.getAxisValue(motionRange.axis, actionPointerIndex) + val axis = motionRange.axis + onNativeAxis(device.descriptor, device.name, axis, axisValue) + } + return true + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/SensorManager.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/SensorManager.kt new file mode 100644 index 00000000..6ac97b5c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/SensorManager.kt @@ -0,0 +1,97 @@ +package info.cemu.cemu.emulation + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.view.Surface +import info.cemu.cemu.nativeinterface.NativeInput + +class SensorManager(context: Context) : SensorEventListener { + private val sensorManager = + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val accelerometer = + sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + private val gyroscope = + sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + + private var deviceRotationProvider = { Surface.ROTATION_0 } + private val hasMotionData = accelerometer != null && gyroscope != null + private var gyroX = 0f + private var gyroY = 0f + private var gyroZ = 0f + private var isListening = false + + fun startListening() { + if (!hasMotionData || isListening) { + return + } + isListening = true + NativeInput.setMotionEnabled(true) + sensorManager.registerListener(this, gyroscope, SensorManager.SENSOR_DELAY_GAME) + sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME) + } + + fun setDeviceRotationProvider(deviceRotationProvider: () -> Int) { + this.deviceRotationProvider = deviceRotationProvider + } + + fun pauseListening() { + if (!hasMotionData || !isListening) { + return + } + isListening = false + NativeInput.setMotionEnabled(false) + sensorManager.unregisterListener(this) + } + + + override fun onSensorChanged(event: SensorEvent) { + val values = event.values + if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { + val gyroValues = getSensorEventValues(values) + gyroX = gyroValues.first + gyroY = gyroValues.second + gyroZ = gyroValues.third + return + } + if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { + return + } + val (accelX, accelY, accelZ) = getSensorEventValues(values) + NativeInput.onMotion(event.timestamp, gyroX, gyroY, gyroZ, accelX, accelZ, -accelY) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + } + + private fun getSensorEventValues(values: FloatArray): Triple<Float, Float, Float> { + val x: Float + val y: Float + val z = values[2] + val deviceRotation = deviceRotationProvider() + when (deviceRotation) { + Surface.ROTATION_90 -> { + x = -values[1] + y = values[0] + } + + Surface.ROTATION_180 -> { + x = -values[0] + y = -values[1] + } + + Surface.ROTATION_270 -> { + x = values[1] + y = -values[0] + } + + else /*Surface.ROTATION_0*/ -> { + x = values[0] + y = values[1] + } + } + return Triple(x, y, z) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/SurfaceChangedListener.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/SurfaceChangedListener.kt new file mode 100644 index 00000000..122f2988 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/SurfaceChangedListener.kt @@ -0,0 +1,23 @@ +package info.cemu.cemu.emulation + +import android.view.SurfaceHolder + +abstract class SurfaceChangedListener : SurfaceHolder.Callback { + override fun surfaceCreated(surfaceHolder: SurfaceHolder) { + + } + + abstract fun surfaceChanged() + + override fun surfaceChanged( + surfaceHolder: SurfaceHolder, + format: Int, + width: Int, + height: Int + ) { + surfaceChanged() + } + + override fun surfaceDestroyed(surfaceHolder: SurfaceHolder) { + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/CanvasExtensions.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/CanvasExtensions.kt new file mode 100644 index 00000000..c9717063 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/CanvasExtensions.kt @@ -0,0 +1,55 @@ +package info.cemu.cemu.emulation.inputoverlay + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF + +fun Canvas.fillCircleWithStroke( + centerX: Float, + centerY: Float, + radius: Float, + paint: Paint, + fillColor: Int, + strokeColor: Int, +) { + paint.color = fillColor + paint.style = Paint.Style.FILL + drawCircle(centerX, centerY, radius, paint) + paint.color = strokeColor + paint.style = Paint.Style.STROKE + drawCircle(centerX, centerY, radius, paint) +} + +fun Canvas.fillRoundRectangleWithStroke( + left: Float, + top: Float, + right: Float, + bottom: Float, + radius: Float, + paint: Paint, + fillColor: Int, + strokeColor: Int, +) { + fillRoundRectangleWithStroke( + RectF(left, top, right, bottom), + radius, + paint, + fillColor, + strokeColor, + ) +} + +fun Canvas.fillRoundRectangleWithStroke( + rect: RectF, + radius: Float, + paint: Paint, + fillColor: Int, + strokeColor: Int, +) { + paint.style = Paint.Style.FILL + paint.color = fillColor + drawRoundRect(rect, radius, radius, paint) + paint.style = Paint.Style.STROKE + paint.color = strokeColor + drawRoundRect(rect, radius, radius, paint) +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/Colors.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/Colors.kt new file mode 100644 index 00000000..c5b892bd --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/Colors.kt @@ -0,0 +1,14 @@ +package info.cemu.cemu.emulation.inputoverlay + +import android.graphics.Color + +object Colors { + fun activeFill(alpha: Int) = Color.argb(alpha, 255, 255, 255) + fun activeStroke(alpha: Int) = Color.argb(alpha, 0, 0, 0) + + fun inactiveFill(alpha: Int) = Color.argb(alpha, 0, 0, 0) + fun inactiveStroke(alpha: Int) = Color.argb(alpha, 255, 255, 255) + + fun backgroundFill(alpha: Int) = Color.argb(alpha, 128, 128, 128) + fun backgroundStroke(alpha: Int) = Color.argb(alpha, 200, 200, 200) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayDefaultConfigParser.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayDefaultConfigParser.kt new file mode 100644 index 00000000..58c2d0cb --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayDefaultConfigParser.kt @@ -0,0 +1,92 @@ +package info.cemu.cemu.emulation.inputoverlay + +import info.cemu.cemu.common.string.toIntOrZero +import org.xmlpull.v1.XmlPullParser + +data class InputConfig( + val width: Int, + val height: Int, + val alignEnd: Boolean, + val alignBottom: Boolean, + val paddingHorizontal: Int, + val paddingVertical: Int, +) + +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" +private const val HEIGHT_CONFIG_TAG_NAME = "height" +private const val ALIGN_END_CONFIG_TAG_NAME = "align-end" +private const val ALIGN_BOTTOM_CONFIG_TAG_NAME = "align-bottom" +private const val PADDING_HORIZONTAL_CONFIG_TAG_NAME = "padding-horizontal" +private const val PADDING_VERTICAL_CONFIG_TAG_NAME = "padding-vertical" +private const val NAME_CONFIG_TAG_NAME = "name" + +private fun parseInputConfig(xmlPullParser: XmlPullParser): Pair<String, InputConfig>? { + var name = "" + var width = 0 + var height = 0 + var alignEnd = false + var alignBottom = false + var paddingHorizontal = 0 + var paddingVertical = 0 + var eventType = xmlPullParser.eventType + var currentTag = "" + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.END_TAG -> { + if (xmlPullParser.name == INPUT_OVERLAY_CONFIG_TAG_NAME) { + return if (name.isBlank()) null + else name to InputConfig( + width = width, + height = height, + alignEnd = alignEnd, + alignBottom = alignBottom, + paddingHorizontal = paddingHorizontal, + paddingVertical = paddingVertical, + ) + } + } + + XmlPullParser.START_TAG -> { + currentTag = xmlPullParser.name + } + + XmlPullParser.TEXT -> { + val text = xmlPullParser.text + when (currentTag) { + SIZE_CONFIG_TAG_NAME -> { + val size = text.toIntOrZero() + width = size + height = size + } + + WIDTH_CONFIG_TAG_NAME -> width = text.toIntOrZero() + HEIGHT_CONFIG_TAG_NAME -> height = text.toIntOrZero() + ALIGN_END_CONFIG_TAG_NAME -> alignEnd = text.toBoolean() + ALIGN_BOTTOM_CONFIG_TAG_NAME -> alignBottom = text.toBoolean() + PADDING_HORIZONTAL_CONFIG_TAG_NAME -> paddingHorizontal = text.toIntOrZero() + PADDING_VERTICAL_CONFIG_TAG_NAME -> paddingVertical = text.toIntOrZero() + NAME_CONFIG_TAG_NAME -> name = text + } + } + } + eventType = xmlPullParser.next() + } + return null +} + +fun parseDefaultInputConfigs(xmlPullParser: XmlPullParser): Map<String, InputConfig> { + val inputConfigs = mutableMapOf<String, InputConfig>() + var eventType = xmlPullParser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && xmlPullParser.name == INPUT_OVERLAY_CONFIG_TAG_NAME) { + val inputConfig = parseInputConfig(xmlPullParser) + if (inputConfig != null) { + inputConfigs[inputConfig.first] = inputConfig.second + } + } + eventType = xmlPullParser.next() + } + return inputConfigs +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayInputsSettingsManager.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayInputsSettingsManager.kt new file mode 100644 index 00000000..7822a568 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlayInputsSettingsManager.kt @@ -0,0 +1,96 @@ +package info.cemu.cemu.emulation.inputoverlay + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Rect +import android.util.DisplayMetrics.DENSITY_DEFAULT +import info.cemu.cemu.R +import kotlin.math.max +import kotlin.math.min + +class InputOverlayInputsSettingsManager(context: Context) { + private val defaultInputConfigs = + parseDefaultInputConfigs(context.resources.getXml(R.xml.input_overlay_default_configs)) + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences(INPUT_OVERLAY_SETTINGS_NAME, Context.MODE_PRIVATE) + + fun getInputOverlayRectangle( + input: OverlayInput, + width: Int, + height: Int, + density: Int, + ): Rect { + return getRectangle(input) ?: getDefaultRectangle(input, width, height, density) + } + + private fun getRectLeftConfigName(input: OverlayInput) = "${input.configName}_LEFT" + private fun getRectTopConfigName(input: OverlayInput) = "${input.configName}_TOP" + private fun getRectRightConfigName(input: OverlayInput) = "${input.configName}_RIGHT" + private fun getRectBottomConfigName(input: OverlayInput) = "${input.configName}_BOTTOM" + + private fun getRectangle(input: OverlayInput): Rect? { + val left = sharedPreferences.getInt(getRectLeftConfigName(input), -1) + val top = sharedPreferences.getInt(getRectTopConfigName(input), -1) + val right = sharedPreferences.getInt(getRectRightConfigName(input), -1) + val bottom = sharedPreferences.getInt(getRectBottomConfigName(input), -1) + if (left == -1 || top == -1 || right == -1 || bottom == -1) { + return null + } + return Rect(left, top, right, bottom) + } + + fun saveRectangle(input: OverlayInput, rect: Rect) { + sharedPreferences.edit().apply { + putInt(getRectLeftConfigName(input), rect.left) + putInt(getRectTopConfigName(input), rect.top) + putInt(getRectRightConfigName(input), rect.right) + putInt(getRectBottomConfigName(input), rect.bottom) + apply() + } + } + + + fun clearSavedRectangle(input: OverlayInput) { + sharedPreferences.edit().apply { + remove(getRectLeftConfigName(input)) + remove(getRectTopConfigName(input)) + remove(getRectRightConfigName(input)) + remove(getRectBottomConfigName(input)) + apply() + } + } + + private fun getDefaultRectangle( + input: OverlayInput, + width: Int, + height: Int, + density: Int, + ): Rect { + fun Int.dpToPx() = (this * density) / DENSITY_DEFAULT + val inputConfig = defaultInputConfigs[input.configName] ?: return Rect() + val inputWidth = inputConfig.width.dpToPx() + val horizontalPadding = inputConfig.paddingHorizontal.dpToPx() + val verticalPadding = inputConfig.paddingVertical.dpToPx() + val inputHeight = inputConfig.height.dpToPx() + val top = min( + max(if (inputConfig.alignBottom) height - inputHeight else 0, verticalPadding), + height - verticalPadding - inputHeight + ) + val left = min( + max(if (inputConfig.alignEnd) width - inputWidth else 0, horizontalPadding), + width - horizontalPadding - inputWidth + ) + val right = left + inputWidth + val bottom = top + inputHeight + return Rect( + left, + top, + right, + bottom, + ) + } + + companion object { + private const val INPUT_OVERLAY_SETTINGS_NAME = "INPUT_OVERLAY_SETTINGS" + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlaySurfaceView.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlaySurfaceView.kt new file mode 100644 index 00000000..55400cc4 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/InputOverlaySurfaceView.kt @@ -0,0 +1,544 @@ +package info.cemu.cemu.emulation.inputoverlay + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import info.cemu.cemu.R +import info.cemu.cemu.common.settings.SettingsManager +import info.cemu.cemu.emulation.inputoverlay.inputs.DPadInput +import info.cemu.cemu.emulation.inputoverlay.inputs.Input +import info.cemu.cemu.emulation.inputoverlay.inputs.Joystick +import info.cemu.cemu.emulation.inputoverlay.inputs.RectangleButton +import info.cemu.cemu.emulation.inputoverlay.inputs.RoundButton +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.BlowButtonInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.ButtonInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.HomeButtonInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.StickClickInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.TextButtonInnerDrawing +import info.cemu.cemu.nativeinterface.NativeInput +import info.cemu.cemu.nativeinterface.NativeInput.getControllerType +import info.cemu.cemu.nativeinterface.NativeInput.isControllerDisabled +import info.cemu.cemu.nativeinterface.NativeInput.onOverlayAxis +import info.cemu.cemu.nativeinterface.NativeInput.onOverlayButton +import kotlin.math.roundToInt + +class InputOverlaySurfaceView(context: Context, attrs: AttributeSet?) : + SurfaceView(context, attrs), OnTouchListener { + enum class InputMode { + DEFAULT, + EDIT_POSITION, + EDIT_SIZE, + } + + private var inputMode = InputMode.DEFAULT + private var pixelDensity = 1 + private var currentAlpha = 255 + private var currentConfiguredInput: Input? = null + private var nativeControllerType = -1 + private var visible = false + private var controllerIndex: Int = 0 + private var onJoystickChange: (OverlayInput, Float, Float, Float, Float) -> Unit = + { _, _, _, _, _ -> } + private var overlyButtonToNativeButton: (OverlayInput) -> Int = { _ -> -1 } + private var inputs: MutableList<Pair<OverlayInput, Input>>? = null + private val inputOverlayInputsSettingsManager: InputOverlayInputsSettingsManager + private val vibrator: Vibrator? + private val buttonTouchVibrationEffect = + VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK) + private var vibrateOnTouch: Boolean = false + private var inputsMinWidthHeight: Int = -1 + + init { + pixelDensity = context.resources.displayMetrics.densityDpi + inputsMinWidthHeight = + (INPUTS_MIN_WIDTH_HEIGHT_DP * (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + vibrator = getVibrator(context) + setOnTouchListener(this) + inputOverlayInputsSettingsManager = InputOverlayInputsSettingsManager(context) + val overlaySettings = SettingsManager.inputOverlaySettings + controllerIndex = overlaySettings.controllerIndex + currentAlpha = overlaySettings.alpha + vibrateOnTouch = vibrator.hasVibrator() && overlaySettings.isVibrateOnTouchEnabled + } + + fun setVisible(visible: Boolean) { + this.visible = visible + invalidate() + } + + fun resetInputs() { + if (inputs == null) { + return + } + for (input in OverlayInputList) { + inputOverlayInputsSettingsManager.clearSavedRectangle(input) + } + inputs!!.clear() + inputs = null + setInputs() + invalidate() + } + + fun setInputMode(inputMode: InputMode) { + this.inputMode = inputMode + if (inputs == null) { + return + } + if (this.inputMode != InputMode.DEFAULT) { + return + } + for ((overlayInput, input) in inputs!!) { + inputOverlayInputsSettingsManager.saveRectangle(overlayInput, input.getBoundingRectangle()) + } + } + + fun getInputMode(): InputMode { + return inputMode + } + + private fun getVibrator(context: Context): Vibrator { + val vibratorManager = + context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + return vibratorManager.defaultVibrator + } + + private fun overlayButtonToVPADButton(button: OverlayInput): Int { + return when (button) { + OverlayButton.A -> NativeInput.VPADButton.A + OverlayButton.B -> NativeInput.VPADButton.B + OverlayButton.X -> NativeInput.VPADButton.X + OverlayButton.Y -> NativeInput.VPADButton.Y + OverlayButton.PLUS -> NativeInput.VPADButton.PLUS + OverlayButton.MINUS -> NativeInput.VPADButton.MINUS + OverlayDpad.DPAD_UP -> NativeInput.VPADButton.UP + OverlayDpad.DPAD_DOWN -> NativeInput.VPADButton.DOWN + OverlayDpad.DPAD_LEFT -> NativeInput.VPADButton.LEFT + OverlayDpad.DPAD_RIGHT -> NativeInput.VPADButton.RIGHT + OverlayButton.L_STICK_CLICK -> NativeInput.VPADButton.STICKL + OverlayButton.R_STICK_CLICK -> NativeInput.VPADButton.STICKR + OverlayButton.L -> NativeInput.VPADButton.L + OverlayButton.R -> NativeInput.VPADButton.R + OverlayButton.ZR -> NativeInput.VPADButton.ZR + OverlayButton.ZL -> NativeInput.VPADButton.ZL + OverlayButton.BLOW_MIC -> NativeInput.VPADButton.MIC + else -> -1 + } + } + + private fun overlayButtonToClassicButton(button: OverlayInput): Int { + return when (button) { + OverlayButton.A -> NativeInput.ClassicButton.A + OverlayButton.B -> NativeInput.ClassicButton.B + OverlayButton.X -> NativeInput.ClassicButton.X + OverlayButton.Y -> NativeInput.ClassicButton.Y + OverlayButton.PLUS -> NativeInput.ClassicButton.PLUS + OverlayButton.MINUS -> NativeInput.ClassicButton.MINUS + OverlayDpad.DPAD_UP -> NativeInput.ClassicButton.UP + OverlayDpad.DPAD_DOWN -> NativeInput.ClassicButton.DOWN + OverlayDpad.DPAD_LEFT -> NativeInput.ClassicButton.LEFT + OverlayDpad.DPAD_RIGHT -> NativeInput.ClassicButton.RIGHT + OverlayButton.L -> NativeInput.ClassicButton.L + OverlayButton.R -> NativeInput.ClassicButton.R + OverlayButton.ZR -> NativeInput.ClassicButton.ZR + OverlayButton.ZL -> NativeInput.ClassicButton.ZL + else -> -1 + } + } + + private fun overlayButtonToProButton(button: OverlayInput): Int { + return when (button) { + OverlayButton.A -> NativeInput.ProButton.A + OverlayButton.B -> NativeInput.ProButton.B + OverlayButton.X -> NativeInput.ProButton.X + OverlayButton.Y -> NativeInput.ProButton.Y + OverlayButton.PLUS -> NativeInput.ProButton.PLUS + OverlayButton.MINUS -> NativeInput.ProButton.MINUS + OverlayDpad.DPAD_UP -> NativeInput.ProButton.UP + OverlayDpad.DPAD_DOWN -> NativeInput.ProButton.DOWN + OverlayDpad.DPAD_LEFT -> NativeInput.ProButton.LEFT + OverlayDpad.DPAD_RIGHT -> NativeInput.ProButton.RIGHT + OverlayButton.L_STICK_CLICK -> NativeInput.ProButton.STICKL + OverlayButton.R_STICK_CLICK -> NativeInput.ProButton.STICKR + OverlayButton.L -> NativeInput.ProButton.L + OverlayButton.R -> NativeInput.ProButton.R + OverlayButton.ZR -> NativeInput.ProButton.ZR + OverlayButton.ZL -> NativeInput.ProButton.ZL + else -> -1 + } + } + + private fun overlayButtonToWiimoteButton(button: OverlayInput): Int { + return when (button) { + OverlayButton.A -> NativeInput.WiimoteButton.A + OverlayButton.B -> NativeInput.WiimoteButton.B + OverlayButton.ONE -> NativeInput.WiimoteButton.ONE + OverlayButton.TWO -> NativeInput.WiimoteButton.TWO + OverlayButton.PLUS -> NativeInput.WiimoteButton.PLUS + OverlayButton.MINUS -> NativeInput.WiimoteButton.MINUS + OverlayButton.HOME -> NativeInput.WiimoteButton.HOME + OverlayDpad.DPAD_UP -> NativeInput.WiimoteButton.UP + OverlayDpad.DPAD_DOWN -> NativeInput.WiimoteButton.DOWN + OverlayDpad.DPAD_LEFT -> NativeInput.WiimoteButton.LEFT + OverlayDpad.DPAD_RIGHT -> NativeInput.WiimoteButton.RIGHT + OverlayButton.C -> NativeInput.WiimoteButton.NUNCHUCK_C + OverlayButton.Z -> NativeInput.WiimoteButton.NUNCHUCK_Z + else -> -1 + } + } + + private fun onButtonStateChange(button: OverlayInput, state: Boolean) { + val nativeButtonId = overlyButtonToNativeButton(button) + if (nativeButtonId == -1) { + return + } + if (vibrateOnTouch && state) { + vibrator!!.vibrate(buttonTouchVibrationEffect) + } + onOverlayButton(controllerIndex, nativeButtonId, state) + } + + private fun onOverlayAxis(axis: Int, value: Float) { + onOverlayAxis(controllerIndex, axis, value) + } + + private fun onVPADJoystickStateChange( + joystick: OverlayInput, + up: Float, + down: Float, + left: Float, + right: Float, + ) { + if (joystick == OverlayJoystick.LEFT) { + onOverlayAxis(NativeInput.VPADButton.STICKL_UP, up) + onOverlayAxis(NativeInput.VPADButton.STICKL_DOWN, down) + onOverlayAxis(NativeInput.VPADButton.STICKL_LEFT, left) + onOverlayAxis(NativeInput.VPADButton.STICKL_RIGHT, right) + } else if (joystick == OverlayJoystick.RIGHT) { + onOverlayAxis(NativeInput.VPADButton.STICKR_UP, up) + onOverlayAxis(NativeInput.VPADButton.STICKR_DOWN, down) + onOverlayAxis(NativeInput.VPADButton.STICKR_LEFT, left) + onOverlayAxis(NativeInput.VPADButton.STICKR_RIGHT, right) + } + } + + private fun onProJoystickStateChange( + joystick: OverlayInput, + up: Float, + down: Float, + left: Float, + right: Float, + ) { + if (joystick == OverlayJoystick.LEFT) { + onOverlayAxis(NativeInput.ProButton.STICKL_UP, up) + onOverlayAxis(NativeInput.ProButton.STICKL_DOWN, down) + onOverlayAxis(NativeInput.ProButton.STICKL_LEFT, left) + onOverlayAxis(NativeInput.ProButton.STICKL_RIGHT, right) + } else if (joystick == OverlayJoystick.RIGHT) { + onOverlayAxis(NativeInput.ProButton.STICKR_UP, up) + onOverlayAxis(NativeInput.ProButton.STICKR_DOWN, down) + onOverlayAxis(NativeInput.ProButton.STICKR_LEFT, left) + onOverlayAxis(NativeInput.ProButton.STICKR_RIGHT, right) + } + } + + private fun onClassicJoystickStateChange( + joystick: OverlayInput, + up: Float, + down: Float, + left: Float, + right: Float, + ) { + if (joystick == OverlayJoystick.LEFT) { + onOverlayAxis(NativeInput.ClassicButton.STICKL_UP, up) + onOverlayAxis(NativeInput.ClassicButton.STICKL_DOWN, down) + onOverlayAxis(NativeInput.ClassicButton.STICKL_LEFT, left) + onOverlayAxis(NativeInput.ClassicButton.STICKL_RIGHT, right) + } else if (joystick == OverlayJoystick.RIGHT) { + onOverlayAxis(NativeInput.ClassicButton.STICKR_UP, up) + onOverlayAxis(NativeInput.ClassicButton.STICKR_DOWN, down) + onOverlayAxis(NativeInput.ClassicButton.STICKR_LEFT, left) + onOverlayAxis(NativeInput.ClassicButton.STICKR_RIGHT, right) + } + } + + private fun onWiimoteJoystickStateChange( + joystick: OverlayInput, + up: Float, + down: Float, + left: Float, + right: Float, + ) { + if (joystick == OverlayJoystick.RIGHT) { + onOverlayAxis(NativeInput.WiimoteButton.NUNCHUCK_UP, up) + onOverlayAxis(NativeInput.WiimoteButton.NUNCHUCK_DOWN, down) + onOverlayAxis(NativeInput.WiimoteButton.NUNCHUCK_LEFT, left) + onOverlayAxis(NativeInput.WiimoteButton.NUNCHUCK_RIGHT, right) + } + } + + private fun onJoystickStateChange(joystick: OverlayInput, x: Float, y: Float) { + val (up, down) = if (y < 0) Pair(-y, 0f) else Pair(0f, y) + val (left, right) = if (x < 0) Pair(-x, 0f) else Pair(0f, x) + onJoystickChange(joystick, up, down, left, right) + } + + private fun getBoundingRectangleForInput(input: OverlayInput): Rect { + return inputOverlayInputsSettingsManager.getInputOverlayRectangle(input, width, height, pixelDensity) + } + + private fun MutableList<Pair<OverlayInput, Input>>.addRoundButton( + button: OverlayButton, + innerDrawing: ButtonInnerDrawing, + ) { + add( + button to RoundButton( + innerDrawing, + { onButtonStateChange(button, it) }, + currentAlpha, + getBoundingRectangleForInput(button), + ) + ) + } + + private fun MutableList<Pair<OverlayInput, Input>>.addRoundButton( + button: OverlayButton, + buttonText: String = button.name, + ) = addRoundButton(button, TextButtonInnerDrawing(buttonText)) + + private fun MutableList<Pair<OverlayInput, Input>>.addJoystick(joystick: OverlayJoystick) { + add( + joystick to Joystick( + { x, y -> onJoystickStateChange(joystick, x, y) }, + currentAlpha, + getBoundingRectangleForInput(joystick) + ) + ) + } + + private fun MutableList<Pair<OverlayInput, Input>>.addDpad() { + add( + OverlayDpad.DPAD_UP to DPadInput( + ::onButtonStateChange, + currentAlpha, + getBoundingRectangleForInput(OverlayDpad.DPAD_UP) + ) + ) + } + + private fun MutableList<Pair<OverlayInput, Input>>.addRectangleButton( + button: OverlayButton, + buttonText: String = button.name, + ) { + add( + button to RectangleButton( + TextButtonInnerDrawing(buttonText), + { onButtonStateChange(button, it) }, + currentAlpha, + getBoundingRectangleForInput(button) + ) + ) + } + + + private fun setInputs() { + if (inputs != null) { + return + } + if (isControllerDisabled(controllerIndex)) { + inputs = mutableListOf() + return + } + + nativeControllerType = getControllerType(controllerIndex) + overlyButtonToNativeButton = when (nativeControllerType) { + NativeInput.EmulatedControllerType.VPAD -> ::overlayButtonToVPADButton + NativeInput.EmulatedControllerType.CLASSIC -> ::overlayButtonToClassicButton + NativeInput.EmulatedControllerType.PRO -> ::overlayButtonToProButton + NativeInput.EmulatedControllerType.WIIMOTE -> ::overlayButtonToWiimoteButton + else -> { _ -> -1 } + } + onJoystickChange = when (nativeControllerType) { + NativeInput.EmulatedControllerType.VPAD -> ::onVPADJoystickStateChange + NativeInput.EmulatedControllerType.PRO -> ::onProJoystickStateChange + NativeInput.EmulatedControllerType.CLASSIC -> ::onClassicJoystickStateChange + NativeInput.EmulatedControllerType.WIIMOTE -> ::onWiimoteJoystickStateChange + else -> { _, _, _, _, _ -> } + } + inputs = mutableListOf<Pair<OverlayInput, Input>>().apply { + addRoundButton(OverlayButton.MINUS, "-") + addRoundButton(OverlayButton.PLUS, "+") + addDpad() + addRoundButton(OverlayButton.A) + addRoundButton(OverlayButton.B) + addJoystick(OverlayJoystick.RIGHT) + if (nativeControllerType != NativeInput.EmulatedControllerType.WIIMOTE) { + addRoundButton(OverlayButton.X) + addRoundButton(OverlayButton.Y) + addRectangleButton(OverlayButton.ZL) + addRectangleButton(OverlayButton.ZR) + addRectangleButton(OverlayButton.L) + addRectangleButton(OverlayButton.R) + addJoystick(OverlayJoystick.LEFT) + } + if (nativeControllerType == NativeInput.EmulatedControllerType.WIIMOTE) { + addRoundButton(OverlayButton.ONE, "1") + addRoundButton(OverlayButton.TWO, "2") + addRoundButton(OverlayButton.C) + addRectangleButton(OverlayButton.Z) + addRoundButton(OverlayButton.HOME, HomeButtonInnerDrawing()) + } + if (nativeControllerType != NativeInput.EmulatedControllerType.CLASSIC + && nativeControllerType != NativeInput.EmulatedControllerType.WIIMOTE + ) { + addRoundButton(OverlayButton.L_STICK_CLICK, StickClickInnerDrawing()) + addRoundButton(OverlayButton.R_STICK_CLICK, StickClickInnerDrawing()) + } + + if (nativeControllerType == NativeInput.EmulatedControllerType.VPAD) { + addRoundButton(OverlayButton.BLOW_MIC, BlowButtonInnerDrawing()) + } + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + setWillNotDraw(false) + setInputs() + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + if (!visible) return + for ((_, input) in inputs!!) { + input.draw(canvas) + } + } + + private fun onEditPosition(event: MotionEvent): Boolean { + val configuredInput = currentConfiguredInput + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + if (configuredInput != null) { + return false + } + val x = event.x + val y = event.y + for ((_, input) in inputs!!) { + if (input.isInside(x, y)) { + currentConfiguredInput = input + input.enableDrawingBoundingRect( + resources.getColor(R.color.purple, context.theme) + ) + return true + } + } + } + + if (configuredInput == null) { + return false + } + + if (event.actionMasked == MotionEvent.ACTION_UP) { + configuredInput.disableDrawingBoundingRect() + currentConfiguredInput = null + return true + } + + if (event.actionMasked == MotionEvent.ACTION_MOVE) { + val x = event.x.toInt() + val y = event.y.toInt() + configuredInput.moveInput(x, y, width, height) + return true + } + + return false + } + + private fun onEditSize(event: MotionEvent): Boolean { + val configuredInput = currentConfiguredInput + + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + + val x = event.x + val y = event.y + for ((_, input) in inputs!!) { + if (input.isInside(x, y)) { + currentConfiguredInput = input + input.enableDrawingBoundingRect( + resources.getColor(R.color.red, context.theme) + ) + return true + } + } + } + + if (configuredInput == null) { + return false + } + + if (event.actionMasked == MotionEvent.ACTION_UP) { + configuredInput.disableDrawingBoundingRect() + currentConfiguredInput = null + return true + } + + if (event.actionMasked == MotionEvent.ACTION_MOVE) { + val histSize = event.historySize + if (event.historySize >= 2) { + val x1 = event.getHistoricalX(0) + val y1 = event.getHistoricalY(0) + val x2 = event.getHistoricalX(histSize - 1) + val y2 = event.getHistoricalY(histSize - 1) + configuredInput.resize( + (x2 - x1).toInt(), + (y2 - y1).toInt(), + width, + height, + inputsMinWidthHeight + ) + } + return true + } + return false + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + var touchEventProcessed = false + when (inputMode) { + InputMode.DEFAULT -> { + for ((_, input) in inputs!!) { + if (input.onTouch(event)) { + touchEventProcessed = true + } + } + } + + InputMode.EDIT_POSITION -> { + touchEventProcessed = onEditPosition(event) + } + + InputMode.EDIT_SIZE -> { + touchEventProcessed = onEditSize(event) + } + } + + if (touchEventProcessed) { + invalidate() + } + + return touchEventProcessed + } + + companion object { + private const val INPUTS_MIN_WIDTH_HEIGHT_DP = 20 + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/OverlayInput.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/OverlayInput.kt new file mode 100644 index 00000000..7ec1fd17 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/OverlayInput.kt @@ -0,0 +1,48 @@ +package info.cemu.cemu.emulation.inputoverlay + +sealed interface OverlayInput { + val configName: String +} + +enum class OverlayJoystick : OverlayInput { + LEFT, + RIGHT; + + override val configName = "AXIS_$name" +} + +enum class OverlayButton : OverlayInput { + A, + B, + ONE, + TWO, + C, + Z, + HOME, + L, + L_STICK_CLICK, + MINUS, + PLUS, + R, + R_STICK_CLICK, + X, + Y, + ZL, + ZR, + BLOW_MIC, + ; + + override val configName = "BUTTON_$name" +} + +enum class OverlayDpad : OverlayInput { + DPAD_DOWN, + DPAD_LEFT, + DPAD_RIGHT, + DPAD_UP; + + override val configName = "DPAD" +} + +val OverlayInputList: List<OverlayInput> = + OverlayButton.entries + OverlayJoystick.entries + OverlayDpad.entries \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Button.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Button.kt new file mode 100644 index 00000000..938dd63e --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Button.kt @@ -0,0 +1,45 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Rect +import android.view.MotionEvent + +abstract class Button( + private val onButtonStateChange: (state: Boolean) -> Unit, + boundingRect: Rect, +) : Input(boundingRect) { + protected var state = false + private var currentPointerId: Int = -1 + + override fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val pointerIndex = event.actionIndex + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + if (isInside(x, y)) { + currentPointerId = event.getPointerId(pointerIndex) + updateState(true) + return true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + if (event.getPointerId(event.actionIndex) == currentPointerId) { + currentPointerId = -1 + updateState(false) + return true + } + } + } + return false + } + + override fun resetInput() { + updateState(false) + } + + private fun updateState(state: Boolean) { + this.state = state + onButtonStateChange(state) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/DPadInput.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/DPadInput.kt new file mode 100644 index 00000000..4f3dae41 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/DPadInput.kt @@ -0,0 +1,213 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.RectF +import android.view.MotionEvent +import info.cemu.cemu.emulation.inputoverlay.fillCircleWithStroke +import info.cemu.cemu.emulation.inputoverlay.fillRoundRectangleWithStroke +import info.cemu.cemu.emulation.inputoverlay.Colors +import info.cemu.cemu.emulation.inputoverlay.OverlayDpad +import kotlin.math.atan2 +import kotlin.math.min + +class DPadInput( + private val onButtonStateChange: (button: OverlayDpad, state: Boolean) -> Unit, + private val alpha: Int, + rect: Rect, +) : Input(rect) { + private var backgroundFillColor: Int = 0 + private var backgroundStrokeColor: Int = 0 + + private val dpadUpRect = RectF() + private val dpadDownRect = RectF() + private val dpadLeftRect = RectF() + private val dpadRightRect = RectF() + + private var dpadState: Int = NONE + + private var centerX: Float = 0f + private var centerY: Float = 0f + private var radius2: Float = 0f + private var radius: Float = 0f + private var currentPointerId: Int = -1 + + override fun configure() { + backgroundFillColor = Colors.backgroundFill(alpha) + backgroundStrokeColor = Colors.backgroundStroke(alpha) + configureColors(alpha) + + centerX = rect.exactCenterX() + centerY = rect.exactCenterY() + radius = min(rect.width(), rect.height()) * 0.5f + radius2 = radius * radius + val buttonSize = radius / 2 + val configureButtonRect = { circleXPos: Float, circleYPos: Float, rect: RectF -> + val left = circleXPos - buttonSize * 0.5f + val top = circleYPos - buttonSize * 0.5f + rect.set(left, top, left + buttonSize, top + buttonSize) + } + val buttonCenterXYTranslate = 2f * radius / 3f + configureButtonRect( + centerX, + centerY - buttonCenterXYTranslate, + dpadUpRect + ) + configureButtonRect( + centerX, + centerY + buttonCenterXYTranslate, + dpadDownRect + ) + configureButtonRect( + centerX - buttonCenterXYTranslate, + centerY, + dpadLeftRect + ) + configureButtonRect( + centerX + buttonCenterXYTranslate, + centerY, + dpadRightRect + ) + } + + init { + configure() + } + + private fun updateState(nextDpadState: Int) { + if (nextDpadState == dpadState) return + updateState(dpadState and nextDpadState.inv(), false) + updateState(nextDpadState and dpadState.inv(), true) + dpadState = nextDpadState + } + + private fun updateState(dpadState: Int, pressed: Boolean) { + if (dpadState == NONE) return + if ((dpadState and UP) != 0) { + onButtonStateChange(OverlayDpad.DPAD_UP, pressed) + } + if ((dpadState and DOWN) != 0) { + onButtonStateChange(OverlayDpad.DPAD_DOWN, pressed) + } + if ((dpadState and LEFT) != 0) { + onButtonStateChange(OverlayDpad.DPAD_LEFT, pressed) + } + if ((dpadState and RIGHT) != 0) { + onButtonStateChange(OverlayDpad.DPAD_RIGHT, pressed) + } + } + + private fun getStateByPosition(x: Float, y: Float): Int { + val dx = x - centerX + val dy = y - centerY + val norm2 = dx * dx + dy * dy + + if (norm2 <= radius2 * 0.1f) { + return NONE + } + + val angle = atan2(dy, dx) + + return when (angle) { + in -0.875 * Math.PI..<-0.625 * Math.PI -> UP or LEFT + in -0.625 * Math.PI..<-0.375 * Math.PI -> UP + in -0.375 * Math.PI..<-0.125 * Math.PI -> UP or RIGHT + in -0.125 * Math.PI..<0.125 * Math.PI -> RIGHT + in 0.125 * Math.PI..<0.375 * Math.PI -> DOWN or RIGHT + in 0.375 * Math.PI..<0.625 * Math.PI -> DOWN + in 0.625 * Math.PI..<0.875 * Math.PI -> DOWN or LEFT + else -> LEFT // in [-pi, -0.875*pi) or [0.875*pi, pi] + } + } + + override fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + if (currentPointerId != -1) { + return false + } + val pointerIndex = event.actionIndex + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + val pointerId = event.getPointerId(pointerIndex) + if (isInside(x, y)) { + currentPointerId = pointerId + updateState(getStateByPosition(x, y)) + return true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + val pointerId = event.getPointerId(event.actionIndex) + if (pointerId == currentPointerId) { + currentPointerId = -1 + updateState(NONE) + return true + } + } + + MotionEvent.ACTION_MOVE -> { + if (currentPointerId == -1) { + return false + } + for (i in 0 until event.pointerCount) { + if (currentPointerId != event.getPointerId(i)) { + continue + } + val x = event.getX(i) + val y = event.getY(i) + updateState(getStateByPosition(x, y)) + return true + } + } + } + return false + } + + override fun resetInput() { + updateState(NONE) + } + + override fun isInside(x: Float, y: Float): Boolean { + val dx = x - centerX + val dy = y - centerY + return dx * dx + dy * dy <= radius2 + } + + override fun drawInput(canvas: Canvas) { + canvas.fillCircleWithStroke( + centerX, + centerY, + radius, + paint, + backgroundFillColor, + backgroundStrokeColor + ) + drawButton(canvas, dpadUpRect, dpadState and UP) + drawButton(canvas, dpadDownRect, dpadState and DOWN) + drawButton(canvas, dpadLeftRect, dpadState and LEFT) + drawButton(canvas, dpadRightRect, dpadState and RIGHT) + } + + private fun drawButton(canvas: Canvas, rect: RectF, state: Int) { + val fillColor: Int + val strokeColor: Int + if (state != 0) { + fillColor = activeFillColor + strokeColor = activeStrokeColor + } else { + fillColor = inactiveFillColor + strokeColor = inactiveStrokeColor + } + canvas.fillRoundRectangleWithStroke(rect, BUTTON_RADIUS, paint, fillColor, strokeColor) + } + + companion object { + private const val BUTTON_RADIUS = 5f + private const val NONE = 0 + private const val UP = 1 shl 0 + private const val DOWN = 1 shl 1 + private const val LEFT = 1 shl 2 + private const val RIGHT = 1 shl 3 + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Input.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Input.kt new file mode 100644 index 00000000..47497a9f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Input.kt @@ -0,0 +1,102 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.MotionEvent +import androidx.annotation.ColorInt +import info.cemu.cemu.emulation.inputoverlay.Colors +import kotlin.math.max +import kotlin.math.min + +abstract class Input protected constructor( + protected var rect: Rect, +) { + private var drawBoundingRectangle = false + private val boundingRectanglePaint = Paint() + protected var activeFillColor = 0 + protected var activeStrokeColor = 0 + protected var inactiveFillColor = 0 + protected var inactiveStrokeColor = 0 + protected val paint = Paint().apply { + strokeWidth = 3f + } + + fun getBoundingRectangle() = rect + + abstract fun onTouch(event: MotionEvent): Boolean + + fun moveInput(x: Int, y: Int, maxWidth: Int, maxHeight: Int) { + val width = rect.width() + val height = rect.height() + val left = min( + max(x - width / 2, 0), + maxWidth - width + ) + val top = min( + max(y - height / 2, 0), + maxHeight - height + ) + rect = Rect( + left, + top, + left + width, + top + height + ) + configure() + } + + fun resize(diffX: Int, diffY: Int, maxWidth: Int, maxHeight: Int, minWidthHeight: Int) { + val newRight = rect.right + diffX + val newBottom = rect.bottom + diffY + if (newRight - rect.left < minWidthHeight + || newBottom - rect.top < minWidthHeight + || newRight > maxWidth + || newBottom > maxHeight + ) { + return + } + rect = Rect( + rect.left, + rect.top, + newRight, + newBottom + ) + configure() + } + + protected abstract fun resetInput() + + fun reset() { + drawBoundingRectangle = false + } + + protected abstract fun configure() + + protected fun configureColors(alpha: Int) { + activeFillColor = Colors.activeFill(alpha) + activeStrokeColor = Colors.activeStroke(alpha) + inactiveFillColor = Colors.inactiveFill(alpha) + inactiveStrokeColor = Colors.inactiveStroke(alpha) + } + + protected abstract fun drawInput(canvas: Canvas) + + fun draw(canvas: Canvas) { + if (drawBoundingRectangle) { + canvas.drawRect(rect, boundingRectanglePaint) + } + drawInput(canvas) + } + + fun enableDrawingBoundingRect(@ColorInt color: Int) { + drawBoundingRectangle = true + boundingRectanglePaint.color = color + } + + fun disableDrawingBoundingRect() { + drawBoundingRectangle = false + } + + abstract fun isInside(x: Float, y: Float): Boolean +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Joystick.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Joystick.kt new file mode 100644 index 00000000..c2e4c91d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/Joystick.kt @@ -0,0 +1,134 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.MotionEvent +import info.cemu.cemu.emulation.inputoverlay.Colors +import info.cemu.cemu.emulation.inputoverlay.fillCircleWithStroke +import kotlin.math.min +import kotlin.math.sqrt + +class Joystick( + private val onStickStateChange: (x: Float, y: Float) -> Unit, + private val alpha: Int, + boundingRectangle: Rect, +) : Input(boundingRectangle) { + private var backgroundFillColor: Int = 0 + private var currentPointerId = -1 + private var pressed = false + private var centerX: Float = 0f + private var centerY: Float = 0f + private var originalCenterX: Float = 0f + private var originalCenterY: Float = 0f + private var radius: Float = 0f + private var innerRadius: Float = 0f + private var innerCenterX: Float = 0f + private var innerCenterY: Float = 0f + + init { + configure() + } + + private fun updateState(pressed: Boolean, x: Float, y: Float) { + this.pressed = pressed + onStickStateChange(x, y) + innerCenterX = centerX + radius * x + innerCenterY = centerY + radius * y + } + + override fun configure() { + backgroundFillColor = Colors.backgroundFill(alpha) + configureColors(alpha) + + centerX = rect.exactCenterX() + originalCenterX = centerX + innerCenterX = centerX + centerY = rect.exactCenterY() + originalCenterY = centerY + innerCenterY = centerY + radius = min(rect.width(), rect.height()) * 0.5f + innerRadius = radius * 0.65f + } + + override fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val pointerIndex = event.actionIndex + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + if (isInside(x, y)) { + centerX = x + centerY = y + currentPointerId = event.getPointerId(pointerIndex) + updateState(true, 0.0f, 0.0f) + return true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + if (currentPointerId == event.getPointerId(event.actionIndex)) { + centerX = originalCenterX + centerY = originalCenterY + currentPointerId = -1 + updateState(false, 0.0f, 0.0f) + return true + } + } + + MotionEvent.ACTION_MOVE -> { + if (currentPointerId == -1) { + return false + } + for (i in 0 until event.pointerCount) { + if (currentPointerId != event.getPointerId(i)) { + continue + } + var x: Float = (event.getX(i) - centerX) / radius + var y: Float = (event.getY(i) - centerY) / radius + val norm = sqrt(x * x + y * y) + if (norm > 1.0f) { + x /= norm + y /= norm + } + updateState(true, x, y) + return true + } + } + } + return false + } + + override fun resetInput() { + updateState(false, 0f, 0f) + } + + override fun drawInput(canvas: Canvas) { + paint.color = backgroundFillColor + paint.style = Paint.Style.FILL + canvas.drawCircle(centerX, centerY, radius, paint) + + val fillColor: Int + val strokeColor: Int + if (pressed) { + fillColor = activeFillColor + strokeColor = activeStrokeColor + } else { + fillColor = inactiveFillColor + strokeColor = inactiveStrokeColor + } + + canvas.fillCircleWithStroke( + innerCenterX, + innerCenterY, + innerRadius, + paint, + fillColor, + strokeColor + ) + } + + override fun isInside(x: Float, y: Float): Boolean { + return (x - originalCenterX) * (x - originalCenterX) + (y - originalCenterY) * (y - originalCenterY) <= radius * radius + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RectangleButton.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RectangleButton.kt new file mode 100644 index 00000000..03d5c706 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RectangleButton.kt @@ -0,0 +1,62 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Canvas +import android.graphics.Rect +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.ButtonInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.fillRoundRectangleWithStroke + +class RectangleButton( + private val innerDrawing: ButtonInnerDrawing, + onButtonStateChange: (state: Boolean) -> Unit, + private val alpha: Int, + rect: Rect, +) : Button(onButtonStateChange, rect) { + var left: Float = 0f + var top: Float = 0f + var right: Float = 0f + var bottom: Float = 0f + + init { + configure() + } + + override fun configure() { + this.left = rect.left.toFloat() + this.top = rect.top.toFloat() + this.right = rect.right.toFloat() + this.bottom = rect.bottom.toFloat() + configureColors(alpha) + innerDrawing.configure(rect, alpha) + } + + override fun drawInput(canvas: Canvas) { + val fillColor: Int + val strokeColor: Int + if (state) { + fillColor = activeFillColor + strokeColor = activeStrokeColor + } else { + fillColor = inactiveFillColor + strokeColor = inactiveStrokeColor + } + canvas.fillRoundRectangleWithStroke( + left, + top, + right, + bottom, + RECTANGLE_RADIUS, + paint, + fillColor, + strokeColor + ) + innerDrawing.draw(canvas, state) + } + + override fun isInside(x: Float, y: Float): Boolean { + return x in left..<right && y in top..<bottom + } + + companion object { + const val RECTANGLE_RADIUS = 5f + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RoundButton.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RoundButton.kt new file mode 100644 index 00000000..64d0c24f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/RoundButton.kt @@ -0,0 +1,52 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs + +import android.graphics.Canvas +import android.graphics.Rect +import info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing.ButtonInnerDrawing +import info.cemu.cemu.emulation.inputoverlay.fillCircleWithStroke +import kotlin.math.min + +class RoundButton( + private val innerDrawing: ButtonInnerDrawing, + onButtonStateChange: (state: Boolean) -> Unit, + private val alpha: Int, + rect: Rect, +) : Button(onButtonStateChange, rect) { + private var centerX: Float = 0f + private var centerY: Float = 0f + private var radius: Float = 0f + private var radius2: Float = 0f + + init { + configure() + } + + override fun configure() { + centerX = rect.centerX().toFloat() + centerY = rect.centerY().toFloat() + radius = min(rect.width().toFloat(), rect.height().toFloat()) / 2f + radius2 = radius * radius + innerDrawing.configure(rect, alpha) + configureColors(alpha) + } + + override fun drawInput(canvas: Canvas) { + val fillColor: Int + val strokeColor: Int + if (state) { + fillColor = activeFillColor + strokeColor = activeStrokeColor + } else { + fillColor = inactiveFillColor + strokeColor = inactiveStrokeColor + } + canvas.fillCircleWithStroke(centerX, centerY, radius, paint, fillColor, strokeColor) + innerDrawing.draw(canvas, state) + } + + override fun isInside(x: Float, y: Float): Boolean { + val dx = x - centerX + val dy = y - centerY + return dx * dx + dy * dy <= radius2 + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/BlowButtonInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/BlowButtonInnerDrawing.kt new file mode 100644 index 00000000..557e89fb --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/BlowButtonInnerDrawing.kt @@ -0,0 +1,17 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Path +import androidx.core.graphics.PathParser + +class BlowButtonInnerDrawing : PathInnerDrawing() { + override val canvasSize: Float = CANVAS_SIZE + override val originalPath: Path + get() = PATH + + companion object { + private const val CANVAS_SIZE = 960f + private const val PATH_DATA = + "M460-160q-50 0-85-35t-35-85h80q0 17 11.5 28.5T460-240q17 0 28.5-11.5T500-280q0-17-11.5-28.5T460-320H80v-80h380q50 0 85 35t35 85q0 50-35 85t-85 35ZM80-560v-80h540q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43h-80q0-59 40.5-99.5T620-840q59 0 99.5 40.5T760-700q0 59-40.5 99.5T620-560H80Zm660 320v-80q26 0 43-17t17-43q0-26-17-43t-43-17H80v-80h660q59 0 99.5 40.5T880-380q0 59-40.5 99.5T740-240Z" + private val PATH by lazy { PathParser.createPathFromPathData(PATH_DATA) } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/ButtonInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/ButtonInnerDrawing.kt new file mode 100644 index 00000000..e5a3ee69 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/ButtonInnerDrawing.kt @@ -0,0 +1,9 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Canvas +import android.graphics.Rect + +interface ButtonInnerDrawing { + fun draw(canvas: Canvas, state: Boolean) + fun configure(boundingRect: Rect, alpha: Int) +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/HomeButtonInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/HomeButtonInnerDrawing.kt new file mode 100644 index 00000000..18bce4f2 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/HomeButtonInnerDrawing.kt @@ -0,0 +1,17 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Path +import androidx.core.graphics.PathParser + +class HomeButtonInnerDrawing : PathInnerDrawing() { + override val canvasSize: Float = CANVAS_SIZE + override val originalPath: Path + get() = PATH + + companion object { + private const val CANVAS_SIZE = 960f + private const val PATH_DATA = + "M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z" + private val PATH by lazy { PathParser.createPathFromPathData(PATH_DATA) } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/PathInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/PathInnerDrawing.kt new file mode 100644 index 00000000..52145344 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/PathInnerDrawing.kt @@ -0,0 +1,41 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import info.cemu.cemu.emulation.inputoverlay.Colors +import kotlin.math.min + +abstract class PathInnerDrawing : ButtonInnerDrawing { + private var activeColor = 0 + private var inactiveColor = 0 + private val paint = Paint() + private var path = Path() + override fun draw(canvas: Canvas, state: Boolean) { + paint.color = if (state) activeColor else inactiveColor + canvas.drawPath(path, paint) + } + + protected abstract val canvasSize: Float + protected abstract val originalPath: Path + + override fun configure(boundingRect: Rect, alpha: Int) { + activeColor = Colors.activeStroke(alpha) + inactiveColor = Colors.inactiveStroke(alpha) + + path = Path(originalPath) + + val transformMatrix = Matrix() + val rectSize = min(boundingRect.width(), boundingRect.height()) * 0.85f + val scale = rectSize / canvasSize + transformMatrix.setScale(scale, scale) + transformMatrix.postTranslate( + boundingRect.exactCenterX() - rectSize * 0.5f, + boundingRect.exactCenterY() + rectSize * 0.5f + ) + + path.transform(transformMatrix) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/StickClickInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/StickClickInnerDrawing.kt new file mode 100644 index 00000000..b8f1710f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/StickClickInnerDrawing.kt @@ -0,0 +1,58 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import info.cemu.cemu.emulation.inputoverlay.Colors +import kotlin.math.min + +class StickClickInnerDrawing : ButtonInnerDrawing { + private var activeColor = 0 + private var inactiveColor = 0 + private val paint = Paint().apply { + strokeWidth = 5f + style = Paint.Style.STROKE + } + private var path = Path() + + override fun draw(canvas: Canvas, state: Boolean) { + paint.color = if (state) activeColor else inactiveColor + canvas.drawPath(path, paint) + } + + override fun configure(boundingRect: Rect, alpha: Int) { + activeColor = Colors.activeStroke(alpha) + inactiveColor = Colors.inactiveStroke(alpha) + val transformMatrix = Matrix() + + path = createArrowPath() + transformMatrix.setTranslate(-0.15f, 0f) + path.transform(transformMatrix) + + transformMatrix.reset() + val arrowPath = createArrowPath() + transformMatrix.setRotate(180f) + transformMatrix.postTranslate(0.15f, 0f) + arrowPath.transform(transformMatrix) + path.addPath(arrowPath) + + transformMatrix.reset() + val scale = min(boundingRect.width(), boundingRect.height()) * 0.5f + transformMatrix.setScale(scale, scale) + transformMatrix.postTranslate( + boundingRect.exactCenterX(), + boundingRect.exactCenterY() + ) + path.transform(transformMatrix) + } + + private fun createArrowPath(): Path { + val arrowPath = Path() + arrowPath.moveTo(-0.5f, 0.5f) + arrowPath.lineTo(0f, 0f) + arrowPath.lineTo(-0.5f, -0.5f) + return arrowPath + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/TextButtonInnerDrawing.kt b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/TextButtonInnerDrawing.kt new file mode 100644 index 00000000..791cda11 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/emulation/inputoverlay/inputs/innerdrawing/TextButtonInnerDrawing.kt @@ -0,0 +1,41 @@ +package info.cemu.cemu.emulation.inputoverlay.inputs.innerdrawing + +import android.graphics.Canvas +import android.graphics.Rect +import android.text.StaticLayout +import android.text.TextPaint +import androidx.core.graphics.withTranslation +import info.cemu.cemu.emulation.inputoverlay.Colors +import kotlin.math.min + +class TextButtonInnerDrawing(private val text: String) : ButtonInnerDrawing { + private val textPaint = TextPaint() + private var staticLayout = createStaticLayout() + private var textXCoordinate = 0f + private var textYCoordinate = 0f + private var activeColor = 0 + private var inactiveColor = 0 + + override fun draw(canvas: Canvas, state: Boolean) { + textPaint.color = if (state) activeColor else inactiveColor + canvas.withTranslation(textXCoordinate, textYCoordinate) { + staticLayout.draw(canvas) + } + } + + override fun configure(boundingRect: Rect, alpha: Int) { + textPaint.textSize = min(boundingRect.width(), boundingRect.height()) * 0.75f + activeColor = Colors.activeStroke(alpha) + inactiveColor = Colors.inactiveStroke(alpha) + staticLayout = createStaticLayout() + textXCoordinate = boundingRect.exactCenterX() - staticLayout.width * 0.5f + textYCoordinate = boundingRect.exactCenterY() - staticLayout.height * 0.5f + } + + private fun createStaticLayout(): StaticLayout { + val textWidth = textPaint.measureText(text).toInt() + return StaticLayout.Builder.obtain(text, 0, text.length, textPaint, textWidth) + .setIncludePad(false) + .build() + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameDetailsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameDetailsScreen.kt new file mode 100644 index 00000000..15aabdfd --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameDetailsScreen.kt @@ -0,0 +1,108 @@ +package info.cemu.cemu.gamelist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.localization.regionToString +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeGameTitles.Game +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun GameDetailsScreen(game: Game?, navigateBack: () -> Unit) { + if (game == null) + return + + ScreenContent( + appBarText = tr("About title"), + contentModifier = Modifier.padding(16.dp), + contentVerticalArrangement = Arrangement.spacedBy(16.dp), + navigateBack = navigateBack, + ) { + GameDetails(game) + } +} + +@Composable +fun GameDetails(game: Game) { + GameIcon( + game = game, + modifier = Modifier.size(128.dp), + ) + TitleDetailsEntry(entryName = tr("Title name"), entryData = game.name) + TitleDetailsEntry(entryName = tr("Title ID"), entryData = game.titleId) + TitleDetailsEntry(entryName = tr("Version"), entryData = game.version) + TitleDetailsEntry(entryName = tr("DLC"), entryData = game.dlc) + TitleDetailsEntry( + entryName = tr("You've played"), + entryData = getTimePlayed(game) + ) + TitleDetailsEntry( + entryName = tr("Last played"), + entryData = getLastPlayedDate(game) + ) + TitleDetailsEntry( + entryName = tr("Region"), + entryData = regionToString(game.region) + ) + TitleDetailsEntry( + entryName = tr("Path"), + entryData = game.path + ) +} + + +private fun getTimePlayed(game: Game): String { + if (game.minutesPlayed == 0) { + return tr("Never played") + } + if (game.minutesPlayed < 60) { + return tr("Minutes: {0}", game.minutesPlayed) + } + return tr( + "Hours: {0} Minutes: {1}", + game.minutesPlayed / 60, + game.minutesPlayed % 60 + ) +} + +private val DateFormatter = DateTimeFormatter.ofLocalizedDate( + FormatStyle.SHORT +) + +private fun getLastPlayedDate(game: Game): String { + if (game.lastPlayedYear.toInt() == 0) { + return tr("Never played") + } + val lastPlayedDate = LocalDate.of( + game.lastPlayedYear.toInt(), + game.lastPlayedMonth.toInt(), + game.lastPlayedDay.toInt() + ) + return DateFormatter.format(lastPlayedDate) +} + +@Composable +private fun <T> TitleDetailsEntry(entryName: String, entryData: T?) { + Column { + Text( + text = entryName, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) + Text( + text = entryData?.toString() ?: "", + fontSize = 16.sp, + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameIcon.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameIcon.kt new file mode 100644 index 00000000..8752fcfd --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameIcon.kt @@ -0,0 +1,45 @@ +package info.cemu.cemu.gamelist + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import info.cemu.cemu.R +import info.cemu.cemu.nativeinterface.NativeGameTitles + + +private fun Modifier.iconBorder(borderColor: Color) = + border( + width = Dp.Hairline, + color = borderColor, + shape = RoundedCornerShape(8.dp) + ).clip(RoundedCornerShape(8.dp)) + +@Composable +fun GameIcon( + game: NativeGameTitles.Game, + modifier: Modifier, +) { + val borderColor = MaterialTheme.colorScheme.onSurface + if (game.icon != null) { + Image( + modifier = modifier.iconBorder(borderColor), + bitmap = game.icon, + contentDescription = null + ) + } else { + Icon( + modifier = modifier.iconBorder(borderColor), + painter = painterResource(R.drawable.ic_question_mark), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListNavigation.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListNavigation.kt new file mode 100644 index 00000000..2593973d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListNavigation.kt @@ -0,0 +1,78 @@ +package info.cemu.cemu.gamelist + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import info.cemu.cemu.nativeinterface.NativeGameTitles +import kotlinx.serialization.Serializable + +@Serializable +object GameListRoute + +private object GameListRoutes { + @Serializable + object GamesRoute + + @Serializable + object GameDetailsRoute + + @Serializable + object GameProfileEditRoute +} + +private inline fun <reified T : Any> NavGraphBuilder.composableGameScreen( + navController: NavController, + noinline content: @Composable (AnimatedContentScope.(NativeGameTitles.Game) -> Unit), +) { + composable<T> { + val previousBackStackEntry = + navController.previousBackStackEntry ?: return@composable + val game = + viewModel<GameViewModel>(previousBackStackEntry).game ?: return@composable + content(game) + } +} + +fun NavGraphBuilder.gameListNavigation( + navController: NavHostController, + startGame: (NativeGameTitles.Game) -> Unit, + createShortcut: (NativeGameTitles.Game) -> Unit, + gameListToolBarActions: @Composable (RowScope.() -> Unit), +) { + navigation<GameListRoute>(startDestination = GameListRoutes.GamesRoute) { + composable<GameListRoutes.GamesRoute> { backStackEntry -> + val gameViewModel: GameViewModel = viewModel(backStackEntry) + GamesListScreen( + startGame = startGame, + createShortcut = createShortcut, + goToGameEditProfile = { game -> + gameViewModel.game = game + navController.navigate(GameListRoutes.GameProfileEditRoute) + }, + goToGameDetails = { game -> + gameViewModel.game = game + navController.navigate(GameListRoutes.GameDetailsRoute) + }, + toolbarActions = gameListToolBarActions + ) + } + composableGameScreen<GameListRoutes.GameDetailsRoute>(navController) { game -> + GameDetailsScreen( + game = game, + navigateBack = { navController.popBackStack() }, + ) + } + composableGameScreen<GameListRoutes.GameProfileEditRoute>(navController) { game -> + GameProfileEditScreen( + game = game, + navigateBack = { navController.popBackStack() }, + ) + } + } +} 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 new file mode 100644 index 00000000..bad6a865 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameListViewModel.kt @@ -0,0 +1,85 @@ +package info.cemu.cemu.gamelist + +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 +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class GameListViewModel : ViewModel() { + private var gamePaths = NativeSettings.getGamesPaths().toSet() + + private val _filterText = MutableStateFlow("") + val filterText = _filterText.asStateFlow() + fun setFilterText(filterText: String) { + _filterText.value = filterText + } + + private val _games = MutableStateFlow<Set<Game>>(emptySet()) + val games: StateFlow<List<Game>> = combine(_filterText, _games) { filter, games -> + if (filter.isBlank()) { + games + } else { + games.filter { it.name?.contains(filter, true) ?: false } + } + }.map { + it.sorted() + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + init { + NativeGameTitles.setGameTitleLoadedCallback(NativeGameTitles.GameTitleLoadedCallback { game: Game -> + if (!game.isValid()) + return@GameTitleLoadedCallback + + if (_games.value.any { it.titleId == game.titleId }) + return@GameTitleLoadedCallback + + _games.value += game + }) + refreshGames() + } + + fun removeShadersForGame(game: Game) { + NativeGameTitles.removeShaderCacheFilesForTitle(game.titleId) + } + + fun setGameTitleFavorite(game: Game, isFavorite: Boolean) { + if (!_games.value.contains(game)) { + return + } + NativeGameTitles.setGameTitleFavorite(game.titleId, isFavorite) + _games.value = _games.value.toMutableSet().apply { + remove(game) + add(game.copy(isFavorite = isFavorite)) + } + } + + override fun onCleared() { + NativeGameTitles.setGameTitleLoadedCallback(null) + } + + fun gamePathsHaveChanged(): 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/GameProfileEditScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameProfileEditScreen.kt new file mode 100644 index 00000000..a7f8cec8 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameProfileEditScreen.kt @@ -0,0 +1,87 @@ +package info.cemu.cemu.gamelist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import info.cemu.cemu.common.ui.components.Header +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeGameTitles + +@Composable +fun GameProfileEditScreen(game: NativeGameTitles.Game?, navigateBack: () -> Unit) { + if (game == null) + return + + val titleId = game.titleId + ScreenContent( + appBarText = tr("Edit game profile"), + navigateBack = navigateBack, + contentModifier = Modifier.padding(16.dp), + contentVerticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(text = game.name) + Toggle( + label = tr("Load shared libraries"), + description = tr("Load libraries from the cafeLibs directory"), + initialCheckedState = { + NativeGameTitles.isLoadingSharedLibrariesForTitleEnabled( + titleId + ) + }, + onCheckedChanged = { enabled -> + NativeGameTitles.setLoadingSharedLibrariesForTitleEnabled( + titleId, + enabled + ) + }, + ) + Toggle( + label = tr("Shader multiplication accuracy"), + description = tr("Controls the accuracy of floating point multiplication in shaders"), + initialCheckedState = { + NativeGameTitles.isShaderMultiplicationAccuracyForTitleEnabled( + titleId + ) + }, + onCheckedChanged = { enabled -> + NativeGameTitles.setShaderMultiplicationAccuracyForTitleEnabled( + titleId, + enabled + ) + }, + ) + SingleSelection( + label = tr("CPU mode"), + initialChoice = { NativeGameTitles.getCpuModeForTitle(titleId) }, + choices = listOf( + NativeGameTitles.CPUMode.SINGLECOREINTERPRETER, + NativeGameTitles.CPUMode.SINGLECORERECOMPILER, + NativeGameTitles.CPUMode.MULTICORERECOMPILER, + NativeGameTitles.CPUMode.AUTO + ), + choiceToString = { cpuMode -> cpuModeToString(cpuMode) }, + onChoiceChanged = { cpuMode -> NativeGameTitles.setCpuModeForTitle(titleId, cpuMode) } + ) + SingleSelection( + label = tr("Thread quantum"), + initialChoice = { NativeGameTitles.getThreadQuantumForTitle(titleId) }, + choices = NativeGameTitles.THREAD_QUANTUM_VALUES.toList(), + choiceToString = { it.toString() }, + onChoiceChanged = { threadQuantum -> + NativeGameTitles.setThreadQuantumForTitle(titleId, threadQuantum) + } + ) + } +} + +private fun cpuModeToString(cpuMode: Int): String = when (cpuMode) { + NativeGameTitles.CPUMode.SINGLECOREINTERPRETER -> tr("Single-core interpreter") + NativeGameTitles.CPUMode.SINGLECORERECOMPILER -> tr("Single-core recompiler") + NativeGameTitles.CPUMode.MULTICORERECOMPILER -> tr("Multi-core recompiler") + else -> tr("Auto (recommended)") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameViewModel.kt new file mode 100644 index 00000000..3e5efae1 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GameViewModel.kt @@ -0,0 +1,6 @@ +package info.cemu.cemu.gamelist + +import androidx.lifecycle.ViewModel +import info.cemu.cemu.nativeinterface.NativeGameTitles.Game + +class GameViewModel(var game: Game? = null) : ViewModel() 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 new file mode 100644 index 00000000..ba952710 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/gamelist/GamesListScreen.kt @@ -0,0 +1,357 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, +) + +package info.cemu.cemu.gamelist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +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.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.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.R +import info.cemu.cemu.common.ui.components.FilledSearchToolbar +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeGameTitles +import info.cemu.cemu.nativeinterface.NativeGameTitles.Game +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun GamesListScreen( + gameListViewModel: GameListViewModel = viewModel(), + goToGameDetails: (Game) -> Unit, + goToGameEditProfile: (Game) -> Unit, + startGame: (Game) -> Unit, + createShortcut: (Game) -> Unit, + toolbarActions: @Composable RowScope.() -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState() + val coroutineScope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val gameSearchQuery by gameListViewModel.filterText.collectAsStateWithLifecycle() + val games by gameListViewModel.games.collectAsStateWithLifecycle() + + val state = rememberPullToRefreshState() + + LaunchedEffect(lifecycleState) { + if (lifecycleState == Lifecycle.State.RESUMED && gameListViewModel.gamePathsHaveChanged()) + gameListViewModel.refreshGames() + } + + DisposableEffect(lifecycleOwner) { + onDispose { + gameListViewModel.setFilterText("") + } + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + FilledSearchToolbar( + actions = toolbarActions, + hint = tr("Search games"), + query = gameSearchQuery, + onValueChange = gameListViewModel::setFilterText + ) + }, + ) { scaffoldPadding -> + Box( + modifier = Modifier + .padding(scaffoldPadding) + .fillMaxSize() + .pullToRefresh( + isRefreshing = refreshing, + state = state, + onRefresh = { + coroutineScope.launch { + refreshing = true + gameListViewModel.refreshGames() + delay(1500) + refreshing = false + } + }, + ), + ) { + GameList( + games = games, + setFavorite = gameListViewModel::setGameTitleFavorite, + deleteShaderCaches = { + coroutineScope.launch { snackbarHostState.showSnackbar(tr("Shader caches removed")) } + gameListViewModel.removeShadersForGame(it) + }, + startGame = startGame, + goToGameDetails = goToGameDetails, + goToGameEditProfile = goToGameEditProfile, + createShortcut = createShortcut, + ) + PullToRefreshDefaults.Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = refreshing, + state = state, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun GameList( + games: List<Game>, + startGame: (Game) -> Unit, + goToGameDetails: (Game) -> Unit, + goToGameEditProfile: (Game) -> Unit, + setFavorite: (Game, Boolean) -> Unit, + createShortcut: (Game) -> Unit, + deleteShaderCaches: (Game) -> Unit, +) { + LazyVerticalGrid( + modifier = Modifier + .padding(8.dp) + .fillMaxSize(), + columns = GridCells.Adaptive(620.dp) + ) { + items(items = games, key = { it.titleId }) { game -> + var showDeleteShaderConfirmationDialog by remember { mutableStateOf(false) } + GameListItem( + modifier = Modifier.animateItem(), + game = game, + onStartGame = startGame, + onIsFavoriteChanged = { isFavorite -> + setFavorite(game, isFavorite) + }, + onEditGameProfile = { + goToGameEditProfile(game) + }, + onRemoveShaderCaches = { showDeleteShaderConfirmationDialog = true }, + onAboutTitle = { + goToGameDetails(game) + }, + onCreateShortcut = { + createShortcut(game) + }, + ) + + if (showDeleteShaderConfirmationDialog) + ShaderCachesConfirmationDialog( + gameName = game.name ?: "", + onDismissRequest = { showDeleteShaderConfirmationDialog = false }, + onConfirm = { + deleteShaderCaches(game) + showDeleteShaderConfirmationDialog = false + }, + ) + + } + } + +} + +@Composable +private fun ShaderCachesConfirmationDialog( + gameName: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + title = { + Text(tr("Remove shader caches")) + }, + text = { + Text(tr("Remove the shader caches for {0}?", gameName)) + }, + dismissButton = { + TextButton( + onClick = onDismissRequest + ) { + Text(tr("No")) + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = onConfirm + ) { + Text(tr("Yes")) + } + } + ) +} + +@Composable +private fun GameListItem( + onStartGame: (Game) -> Unit, + onIsFavoriteChanged: (Boolean) -> Unit, + onEditGameProfile: () -> Unit, + onRemoveShaderCaches: () -> Unit, + onAboutTitle: () -> Unit, + onCreateShortcut: () -> Unit, + game: Game, + modifier: Modifier = Modifier, +) { + var contextMenuExpanded by rememberSaveable { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .combinedClickable( + onClick = { onStartGame(game) }, + onLongClick = { + contextMenuExpanded = true + }, + ) + .padding(8.dp) + .fillMaxWidth(), + ) { + Box { + GameIcon( + game = game, + modifier = Modifier.size(60.dp), + ) + if (game.isFavorite) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(2.dp) + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + painter = painterResource(R.drawable.ic_favorite), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null + ) + } + } + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = game.name ?: "", + fontSize = 24.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + GameContextMenu( + expanded = contextMenuExpanded, + onDismissRequest = { contextMenuExpanded = false }, + game = game, + onIsFavoriteChanged = onIsFavoriteChanged, + onEditGameProfile = onEditGameProfile, + onRemoveShaderCaches = onRemoveShaderCaches, + onAboutTitle = onAboutTitle, + onCreateShortcut = onCreateShortcut, + ) + } +} + +@Composable +private fun GameContextMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + onIsFavoriteChanged: (Boolean) -> Unit, + onEditGameProfile: () -> Unit, + onRemoveShaderCaches: () -> Unit, + onAboutTitle: () -> Unit, + onCreateShortcut: () -> Unit, + game: Game, +) { + @Composable + fun GameContextMenuItem( + onClick: () -> Unit, + text: String, + enabled: Boolean = true, + trailingIcon: @Composable (() -> Unit)? = null, + ) { + DropdownMenuItem( + enabled = enabled, + onClick = { + onDismissRequest() + onClick() + }, + text = { + Text(text = text) + }, + trailingIcon = trailingIcon, + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest + ) { + val gameTitleHasCaches = rememberSaveable { + NativeGameTitles.titleHasShaderCacheFiles(game.titleId) + } + GameContextMenuItem( + onClick = { onIsFavoriteChanged(!game.isFavorite) }, + text = tr("Favorite"), + trailingIcon = { + Checkbox(checked = game.isFavorite, onCheckedChange = null) + } + ) + GameContextMenuItem( + onClick = onEditGameProfile, + text = tr("Edit game profile") + ) + GameContextMenuItem( + enabled = gameTitleHasCaches, + onClick = { + onRemoveShaderCaches() + }, + text = tr("Remove shader caches") + ) + GameContextMenuItem( + onClick = onAboutTitle, + text = tr("About title"), + ) + GameContextMenuItem( + onClick = onCreateShortcut, + text = tr("Create shortcut") + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPackNodes.kt b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPackNodes.kt new file mode 100644 index 00000000..ac951e28 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPackNodes.kt @@ -0,0 +1,116 @@ +package info.cemu.cemu.graphicpacks + +import info.cemu.cemu.nativeinterface.NativeGraphicPacks +import kotlin.math.max + +sealed class GraphicPackNode( + val name: String?, + val parent: GraphicPackSectionNode? +) { + var titleIdInstalled: Boolean = false + protected set + + fun isRoot() = parent == null +} + +class GraphicPackSectionNode : GraphicPackNode { + constructor() : super(null, null) + + constructor( + name: String, + titleIdInstalled: Boolean, + parent: GraphicPackSectionNode + ) : super(name, parent) { + this.titleIdInstalled = titleIdInstalled + } + + var enabledGraphicPacksCount: Int = 0 + private set + var children: ArrayList<GraphicPackNode> = ArrayList() + private set + + fun clear() { + children.clear() + } + + fun addGraphicPackDataByTokens( + graphicPackBasicInfo: NativeGraphicPacks.GraphicPackBasicInfo, + titleIdInstalled: Boolean + ) { + var node = this + val tokens = graphicPackBasicInfo.virtualPath.split("/") + if (tokens.isEmpty()) { + return + } + for (token in tokens.subList(0, tokens.size - 1)) { + node = getOrAddSectionByToken(node, token, node.children, titleIdInstalled) + } + if (graphicPackBasicInfo.enabled) { + node.updateEnabledCount(true) + } + node.children.add( + GraphicPackDataNode( + graphicPackBasicInfo.id, + tokens.last(), + graphicPackBasicInfo.virtualPath, + graphicPackBasicInfo.enabled, + titleIdInstalled, + node + ) + ) + } + + private fun getOrAddSectionByToken( + parent: GraphicPackSectionNode, + token: String, + graphicPackNodes: ArrayList<GraphicPackNode>, + titleIdInstalled: Boolean + ): GraphicPackSectionNode { + val existingSectionNode = + graphicPackNodes.firstOrNull { it is GraphicPackSectionNode && it.name == token } + if (existingSectionNode != null) { + return existingSectionNode as GraphicPackSectionNode + } + val sectionNode = GraphicPackSectionNode(token, titleIdInstalled, parent) + graphicPackNodes.add(sectionNode) + return sectionNode + } + + fun sort() { + children.forEach { if (it is GraphicPackSectionNode) it.sort() } + children.sortBy { it.name } + } + + fun updateEnabledCount(enabled: Boolean) { + enabledGraphicPacksCount = max(0, enabledGraphicPacksCount + if (enabled) 1 else -1) + parent?.updateEnabledCount(enabled) + } +} + +class GraphicPackDataNode( + val id: Long, + name: String, + val path: String, + enabled: Boolean, + parent: GraphicPackSectionNode, +) : GraphicPackNode(name, parent) { + constructor( + id: Long, + name: String, + path: String, + enabled: Boolean, + titleIdInstalled: Boolean, + parentNode: GraphicPackSectionNode, + ) : this(id, name, path, enabled, parentNode) { + this.titleIdInstalled = titleIdInstalled + } + + var enabled: Boolean = enabled + set(value) { + if (field == value) { + return + } + field = value + parent?.updateEnabledCount(value) + } +} 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 new file mode 100644 index 00000000..b9a633ee --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksDownloader.kt @@ -0,0 +1,144 @@ +package info.cemu.cemu.graphicpacks + +import android.content.Context +import info.cemu.cemu.BuildConfig +import info.cemu.cemu.nativeinterface.NativeGraphicPacks +import info.cemu.cemu.common.io.unzip +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import org.json.JSONObject +import java.io.File +import java.io.IOException +import kotlin.io.path.div +import kotlin.io.path.readText + +enum class GraphicPacksDownloadStatus { + CHECKING_VERSION, + NO_UPDATES_AVAILABLE, + DOWNLOADING, + EXTRACTING, + FINISHED_DOWNLOADING, + ERROR, + CANCELED +} + +class GraphicPacksDownloader { + private fun getCurrentVersion(graphicPacksDir: File): String? { + val graphicPacksVersionFile = + graphicPacksDir.toPath() / "downloadedGraphicPacks" / "version.txt" + return try { + graphicPacksVersionFile.readText() + } catch (_: IOException) { + null + } + } + + suspend fun download( + context: Context, + updateStatus: suspend (GraphicPacksDownloadStatus) -> Unit + ) { + val graphicPacksRootDir = context.getExternalFilesDir(null) + if (graphicPacksRootDir == null) { + updateStatus(GraphicPacksDownloadStatus.ERROR) + return + } + + val graphicPacksDirPath = graphicPacksRootDir.toPath() / "graphicPacks" + checkForNewUpdate(graphicPacksDirPath.toFile(), updateStatus) + } + + private suspend fun getUpdateUrl(): String { + val queryUrl = "https://cemu.info/api2/query_graphicpack_url.php?" + + "version=${BuildConfig.VERSION_NAME}" + + "&t=${System.currentTimeMillis()}" + + val request = Request.Builder() + .url(queryUrl) + .build() + + Client.newCall(request).executeAsync().use { response -> + if (response.isSuccessful) { + val body = response.body.string().trim() + if (body.startsWith("http")) { + return body + } + } + } + + return "https://api.github.com/repos/cemu-project/cemu_graphic_packs/releases/latest" + } + + private suspend fun checkForNewUpdate( + graphicPacksDir: File, + updateStatus: suspend (GraphicPacksDownloadStatus) -> Unit + ) { + updateStatus(GraphicPacksDownloadStatus.CHECKING_VERSION) + val request = Request.Builder() + .url(getUpdateUrl()) + .build() + Client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (!response.isSuccessful) { + updateStatus(GraphicPacksDownloadStatus.ERROR) + return@withContext + } + val json = JSONObject(response.body.string()) + val version = json.getString("name") + if (getCurrentVersion(graphicPacksDir) == version) { + updateStatus(GraphicPacksDownloadStatus.NO_UPDATES_AVAILABLE) + return@withContext + } + val downloadUrl = json.getJSONArray("assets") + .getJSONObject(0) + .getString("browser_download_url") + downloadNewUpdate(graphicPacksDir, downloadUrl, version, updateStatus) + } + } + } + + private suspend fun downloadNewUpdate( + graphicPacksDir: File, + downloadUrl: String, + version: String, + updateStatus: suspend (GraphicPacksDownloadStatus) -> Unit + ) { + updateStatus(GraphicPacksDownloadStatus.DOWNLOADING) + + val request = Request.Builder() + .url(downloadUrl) + .build() + + Client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (!response.isSuccessful) { + updateStatus(GraphicPacksDownloadStatus.ERROR) + return@withContext + } + + updateStatus(GraphicPacksDownloadStatus.EXTRACTING) + + val graphicPacksTempDir = graphicPacksDir.resolve("downloadedGraphicPacksTemp") + graphicPacksTempDir.deleteRecursively() + unzip( + response.body.byteStream(), + graphicPacksTempDir.path + ) + graphicPacksTempDir.resolve("version.txt").writeText(version) + val downloadedGraphicPacksDir = + graphicPacksDir.resolve("downloadedGraphicPacks") + downloadedGraphicPacksDir.deleteRecursively() + graphicPacksTempDir.renameTo(downloadedGraphicPacksDir) + NativeGraphicPacks.refreshGraphicPacks() + + updateStatus(GraphicPacksDownloadStatus.FINISHED_DOWNLOADING) + } + } + } + + companion object { + private val Client = OkHttpClient() + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksNavigation.kt b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksNavigation.kt new file mode 100644 index 00000000..f5cdc078 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksNavigation.kt @@ -0,0 +1,17 @@ +package info.cemu.cemu.graphicpacks + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +object GraphicPacksRoute + +fun NavGraphBuilder.graphicPacksNavigation(navController: NavHostController) { + composable<GraphicPacksRoute> { + GraphicPacksScreen( + navigateBack = { navController.popBackStack() } + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksScreen.kt new file mode 100644 index 00000000..cc6d15ae --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksScreen.kt @@ -0,0 +1,459 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package info.cemu.cemu.graphicpacks + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.R +import info.cemu.cemu.common.ui.components.DefaultAppBarTitle +import info.cemu.cemu.common.ui.components.ScreenContentLazy +import info.cemu.cemu.common.ui.components.SearchToolbarInput +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.localization.tr +import kotlinx.coroutines.launch + +@Composable +fun GraphicPacksScreen( + navigateBack: () -> Unit, + graphicPacksViewModel: GraphicPacksViewModel = viewModel(), +) { + val graphicPackDataNodes by graphicPacksViewModel.graphicPackDataNodes.collectAsState() + val query by graphicPacksViewModel.filterText.collectAsState() + val installedOnly by graphicPacksViewModel.installedOnly.collectAsState() + var showGraphicPackSearch by rememberSaveable { mutableStateOf(false) } + val downloadStatus by graphicPacksViewModel.downloadStatus.collectAsState() + val snackbarScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var downloadDialogText by rememberSaveable { mutableStateOf<String?>(null) } + val context = LocalContext.current + val currentNodeState = graphicPacksViewModel.currentNode.collectAsState() + val currentNode = currentNodeState.value + val graphicPackDataState = graphicPacksViewModel.currentDataGraphicPack.collectAsState() + val graphicPackData = graphicPackDataState.value + + downloadStatus?.let { status -> + downloadDialogText = downloadStatusToDialogTextString(status) + LaunchedEffect(status) { + graphicPacksViewModel.downloadStatusRead() + + val downloadNotificationText = + downloadStatusToNotificationString(status) ?: return@LaunchedEffect + + snackbarScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(downloadNotificationText) + } + } + } + + fun handleBack() { + if (showGraphicPackSearch) { + showGraphicPackSearch = false + return + } + + if (!currentNodeState.value.isRoot()) { + graphicPacksViewModel.navigateBack() + return + } + + navigateBack() + } + + BackHandler(enabled = showGraphicPackSearch || !currentNodeState.value.isRoot()) { + handleBack() + } + + ScreenContentLazy( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + actions = { + if (currentNode.isRoot()) { + GraphicPacksRootSectionActions( + showMainActions = !showGraphicPackSearch, + onSearchClicked = { + showGraphicPackSearch = true + }, + onDownloadClicked = { graphicPacksViewModel.downloadNewUpdate(context) }, + installedOnlyChecked = installedOnly, + installedOnlyValueChange = graphicPacksViewModel::setInstalledOnly, + ) + } + }, + appBarTitle = { + Box( + modifier = Modifier + .height(IntrinsicSize.Min) + .padding(8.dp), + ) { + if (showGraphicPackSearch && currentNode.isRoot()) { + SearchToolbarInput( + value = query, + onValueChange = graphicPacksViewModel::setFilterText, + hint = tr("Search graphic packs"), + ) + } else { + DefaultAppBarTitle(currentNode.name ?: tr("Graphic packs")) + } + } + }, + navigateBack = ::handleBack, + ) { + if (showGraphicPackSearch && currentNode.isRoot()) { + graphicPackDataSearchItems( + nodes = graphicPackDataNodes, + onClick = { graphicPacksViewModel.navigateTo(it) }, + ) + return@ScreenContentLazy + } + + if (currentNode is GraphicPackSectionNode) { + graphicPackSectionItems( + installedOnly = installedOnly, + nodes = currentNode.children, + onClick = { graphicPacksViewModel.navigateTo(it) }, + ) + } + + if (graphicPackData != null) { + graphicPackDataNodeItem( + graphicPacksViewModel = graphicPacksViewModel, + graphicPackData = graphicPackData + ) + } + + } + if (downloadDialogText != null) { + GraphicPacksDownloadDialog( + onCancelRequest = { + graphicPacksViewModel.cancelDownload() + }, + text = downloadDialogText!!, + ) + } +} + +private fun downloadStatusToDialogTextString(downloadStatus: GraphicPacksDownloadStatus?): String? = + when (downloadStatus) { + GraphicPacksDownloadStatus.CHECKING_VERSION -> tr("Checking version...") + GraphicPacksDownloadStatus.DOWNLOADING -> tr("Downloading graphic packs...") + GraphicPacksDownloadStatus.EXTRACTING -> tr("Extracting...") + else -> null + } + +private fun downloadStatusToNotificationString(downloadStatus: GraphicPacksDownloadStatus?): String? = + when (downloadStatus) { + GraphicPacksDownloadStatus.ERROR -> tr("Failed to download graphic packs") + GraphicPacksDownloadStatus.FINISHED_DOWNLOADING -> tr("Downloaded latest graphic packs") + GraphicPacksDownloadStatus.NO_UPDATES_AVAILABLE -> tr("No updates available.") + else -> null + } + +@Composable +private fun GraphicPacksDownloadDialog( + onCancelRequest: () -> Unit, + text: String, +) { + AlertDialog( + title = { + Text(text = tr("Graphic packs download")) + }, + text = { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Text( + text = text, + modifier = Modifier.padding(bottom = 16.dp) + ) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + }, + onDismissRequest = {}, + confirmButton = { + TextButton( + onClick = { + onCancelRequest() + } + ) { + Text(tr("Cancel")) + } + } + ) +} + +@Composable +private fun GraphicPacksRootSectionActions( + showMainActions: Boolean, + onSearchClicked: () -> Unit, + onDownloadClicked: () -> Unit, + installedOnlyChecked: Boolean, + installedOnlyValueChange: (Boolean) -> Unit, +) { + if (showMainActions) { + IconButton( + onClick = onSearchClicked + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null + ) + } + IconButton( + onClick = onDownloadClicked + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = null + ) + } + } + var showMoreOptions by rememberSaveable { mutableStateOf(false) } + IconButton( + onClick = { showMoreOptions = true } + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } + DropdownMenu( + expanded = showMoreOptions, + onDismissRequest = { showMoreOptions = false } + ) { + DropdownMenuItem( + onClick = { + installedOnlyValueChange(!installedOnlyChecked) + }, + text = { + Text(text = tr("Installed only")) + }, + trailingIcon = { + Checkbox( + checked = installedOnlyChecked, + onCheckedChange = null + ) + } + ) + } +} + +private fun LazyListScope.graphicPackDataSearchItems( + nodes: List<GraphicPackDataNode>, + onClick: (GraphicPackDataNode) -> Unit, +) { + items(nodes) { + Row( + modifier = Modifier + .animateItem() + .clickable(onClick = dropUnlessResumed { onClick(it) }) + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + GraphicPackDataListItemIcon(it.enabled) + Column { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = it.name ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = it.path, + ) + } + } + } +} + +private fun LazyListScope.graphicPackDataNodeItem( + graphicPacksViewModel: GraphicPacksViewModel, + graphicPackData: GraphicPackData +) { + item { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = tr("Enabled")) + Switch( + modifier = Modifier.padding(horizontal = 8.dp), + checked = graphicPackData.active, + onCheckedChange = graphicPacksViewModel::setCurrentGraphicPackActive, + ) + } + } + item { + Text( + modifier = Modifier.padding(8.dp), + text = graphicPackData.description + ) + } + items(items = graphicPackData.presets) { + SingleSelection( + modifier = Modifier.animateItem(), + label = it.category ?: tr("Active preset"), + choices = it.choices, + choice = it.activeChoice, + onChoiceChanged = { activePreset -> + graphicPacksViewModel.setCurrentGraphicPackActivePreset( + it.index, + activePreset + ) + } + ) + } +} + +private fun LazyListScope.graphicPackSectionItems( + nodes: List<GraphicPackNode>, + installedOnly: Boolean, + onClick: (GraphicPackNode) -> Unit, +) { + items( + items = if (installedOnly) nodes.filter { it.titleIdInstalled } else nodes, + ) { + GraphicPackListItem( + label = it.name, + onClick = dropUnlessResumed { onClick(it) }, + modifier = Modifier.animateItem(), + ) { + when (it) { + is GraphicPackSectionNode -> GraphicPackSectionListItemIcon(it.enabledGraphicPacksCount) + is GraphicPackDataNode -> GraphicPackDataListItemIcon(it.enabled) + } + } + } +} + +@Composable +private fun GraphicPackDataListItemIcon(isEnabled: Boolean) { + GraphicPackListItemIcon( + painter = painterResource(R.drawable.ic_package_2), + showExtraInfo = isEnabled, + ) { + Icon( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, CircleShape) + .size(16.dp), + imageVector = Icons.Filled.Check, + tint = contentColorFor(MaterialTheme.colorScheme.primary), + contentDescription = null + ) + } +} + +@Composable +private fun GraphicPackSectionListItemIcon(numberOfEnabledPacks: Int) { + GraphicPackListItemIcon( + painter = painterResource(R.drawable.ic_lists), + showExtraInfo = numberOfEnabledPacks > 0, + ) { + Text( + textAlign = TextAlign.Center, + text = if (numberOfEnabledPacks < 99) numberOfEnabledPacks.toString() else "99+", + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)) + .padding(horizontal = 2.dp), + color = contentColorFor(MaterialTheme.colorScheme.primary), + ) + } +} + +@Composable +private fun GraphicPackListItemIcon( + painter: Painter, + showExtraInfo: Boolean, + extraInfoContent: @Composable () -> Unit, +) { + Box { + Icon( + modifier = Modifier.size(28.dp), + painter = painter, + contentDescription = null + ) + if (showExtraInfo) { + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + extraInfoContent() + } + } + } +} + +@Composable +private fun GraphicPackListItem( + label: String?, + onClick: () -> Unit, + modifier: Modifier, + icon: @Composable () -> Unit, +) { + Row( + modifier = modifier + .clickable(onClick = onClick) + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Text( + modifier = Modifier + .weight(1.0f) + .padding(horizontal = 8.dp), + text = label ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksViewModel.kt new file mode 100644 index 00000000..4813a44c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/graphicpacks/GraphicPacksViewModel.kt @@ -0,0 +1,274 @@ +package info.cemu.cemu.graphicpacks + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import info.cemu.cemu.nativeinterface.NativeGameTitles +import info.cemu.cemu.nativeinterface.NativeGraphicPacks +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.regex.Pattern + +data class Preset( + val index: Int, + val category: String?, + val activeChoice: String, + val choices: List<String>, +) { + companion object { + fun fromNativeGraphicPack(graphicPack: NativeGraphicPacks.GraphicPack): List<Preset> { + return graphicPack.presets.mapIndexed { index, preset -> + Preset( + index = index, + category = preset.category, + activeChoice = preset.activePreset, + choices = preset.presets, + ) + } + } + + } +} + +data class GraphicPackData( + val description: String, + val active: Boolean, + val presets: List<Preset>, +) + +private fun MutableList<GraphicPackDataNode>.fillWithDataNodes(graphicPackSectionNode: GraphicPackSectionNode): MutableList<GraphicPackDataNode> { + for (node in graphicPackSectionNode.children) { + when (node) { + is GraphicPackSectionNode -> fillWithDataNodes(node) + is GraphicPackDataNode -> add(node) + } + } + return this +} + +class GraphicPacksViewModel : ViewModel() { + private var rootNode = GraphicPackSectionNode() + + val installedTitleIds = NativeGameTitles.getInstalledGamesTitleIds() + + private val _installedOnly = MutableStateFlow(installedTitleIds.size > 1) + val installedOnly = _installedOnly.asStateFlow() + fun setInstalledOnly(installedOnly: Boolean) { + _installedOnly.value = installedOnly + } + + private val path = MutableStateFlow(listOf<GraphicPackNode>(rootNode)) + + val currentNode = path.map { it.last() } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + rootNode + ) + + private var currentNativeGraphicPack: NativeGraphicPacks.GraphicPack? = null + private val _currentDataGraphicPack = MutableStateFlow<GraphicPackData?>(null) + val currentDataGraphicPack = _currentDataGraphicPack.asStateFlow() + + private fun setCurrentDataGraphicPack(graphicPackDataNode: GraphicPackDataNode) { + val nativeGraphicPack = NativeGraphicPacks.getGraphicPack(graphicPackDataNode.id) ?: return + currentNativeGraphicPack = nativeGraphicPack + _currentDataGraphicPack.value = GraphicPackData( + description = nativeGraphicPack.description, + active = nativeGraphicPack.isActive(), + presets = Preset.fromNativeGraphicPack(nativeGraphicPack) + ) + } + + private fun clearCurrentDataGraphicPack() { + currentNativeGraphicPack = null + _currentDataGraphicPack.value = null + } + + fun setCurrentGraphicPackActive(active: Boolean) { + val dataNode = currentNode.value + if (dataNode !is GraphicPackDataNode) { + return + } + dataNode.enabled = active + currentNativeGraphicPack?.setActive(active) + _currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy(active = active) + } + + private fun refreshCurrentGraphicPackPresets() { + val nativeGraphicPack = currentNativeGraphicPack ?: return + + val oldPresets = nativeGraphicPack.presets + + nativeGraphicPack.reloadPresets() + + if (oldPresets == nativeGraphicPack.presets) { + return + } + + _currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy( + presets = Preset.fromNativeGraphicPack(nativeGraphicPack) + ) + } + + fun setCurrentGraphicPackActivePreset(index: Int, activePreset: String) { + val nativeGraphicPack = currentNativeGraphicPack ?: return + + var presets = _currentDataGraphicPack.value?.presets ?: return + + presets = presets.toMutableList().apply { + set(index, get(index).copy(activeChoice = activePreset)) + } + + _currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy(presets = presets) + + nativeGraphicPack.presets[index].activePreset = activePreset + + refreshCurrentGraphicPackPresets() + } + + + fun navigateBack() { + val currentPath = path.value + val lastNode = currentPath.last() + + if (currentPath.size > 1) { + path.value = currentPath.dropLast(1) + } + + if (lastNode is GraphicPackDataNode) { + clearCurrentDataGraphicPack() + } + } + + fun navigateTo(node: GraphicPackNode) { + val currentPath = path.value + + if (node is GraphicPackSectionNode) { + path.value += node + return + } + + if (node !is GraphicPackDataNode) { + return + } + + setCurrentDataGraphicPack(node) + + if (currentPath.last() === node.parent) { + path.value += node + return + } + + val newPath = mutableListOf<GraphicPackNode>(node) + var currentNode = node.parent + while (currentNode != null) { + newPath.add(currentNode) + currentNode = currentNode.parent + } + + path.value = newPath.reversed() + } + + private val _downloadStatus = MutableStateFlow<GraphicPacksDownloadStatus?>(null) + private suspend fun updateDownloadStatus(status: GraphicPacksDownloadStatus?) { + _downloadStatus.first { it == null } + _downloadStatus.value = status + } + + val downloadStatus = _downloadStatus.asStateFlow() + + private var downloadJob: Job? = null + fun downloadNewUpdate(context: Context) { + if (_downloadStatus.value != null) return + downloadJob = viewModelScope.launch { + try { + GraphicPacksDownloader.download(context) { updateDownloadStatus(it) } + refreshGraphicPacks() + } catch (_: Exception) { + updateDownloadStatus(GraphicPacksDownloadStatus.ERROR) + } + } + } + + fun downloadStatusRead() { + _downloadStatus.value = null + } + + fun cancelDownload() { + val oldDownloadJob = downloadJob ?: return + downloadJob = null + viewModelScope.launch { + oldDownloadJob.cancelAndJoin() + updateDownloadStatus(GraphicPacksDownloadStatus.CANCELED) + } + } + + private val _filterText = MutableStateFlow("") + val filterText: StateFlow<String> = _filterText + fun setFilterText(filterText: String) { + _filterText.value = filterText + } + + private val filterPattern = filterText.map { filterText -> + if (filterText.isBlank()) { + return@map null + } + return@map buildString { + filterText.trim().split(" ".toRegex()) + .forEach { append("(?=.*" + Pattern.quote(it) + ")") } + append(".*") + }.toPattern(Pattern.CASE_INSENSITIVE) + } + + private val _graphicPackDataNodes = MutableStateFlow<List<GraphicPackDataNode>>(emptyList()) + val graphicPackDataNodes: StateFlow<List<GraphicPackDataNode>> = combine( + _graphicPackDataNodes, installedOnly, filterPattern + ) { graphicPackNodes, installedOnly, pattern -> + if (!installedOnly && pattern == null) { + return@combine graphicPackNodes + } + if (pattern != null) { + return@combine graphicPackNodes.filter { + pattern.matcher(it.path).matches() && (it.titleIdInstalled || !installedOnly) + } + } + return@combine graphicPackNodes.filter { it.titleIdInstalled } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + private fun refreshGraphicPacks() { + rootNode = GraphicPackSectionNode().apply { + NativeGraphicPacks.getGraphicPackBasicInfos().forEach { + val hasTitleInstalled = it.titleIds.any { titleId -> titleId in installedTitleIds } + addGraphicPackDataByTokens(it, hasTitleInstalled) + } + sort() + } + + path.value = listOf(rootNode) + + _graphicPackDataNodes.value = + mutableListOf<GraphicPackDataNode>().fillWithDataNodes(rootNode) + } + + init { + refreshGraphicPacks() + } + + companion object { + private val GraphicPacksDownloader = GraphicPacksDownloader() + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeAccount.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeAccount.kt new file mode 100644 index 00000000..2edd48e1 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeAccount.kt @@ -0,0 +1,84 @@ +package info.cemu.cemu.nativeinterface + +import androidx.annotation.Keep + +object NativeAccount { + const val MAX_ACCOUNT_COUNT = 12 + const val MIN_ACCOUNT_COUNT = 1 + const val MIN_PERSISTENT_ID: UInt = 0x80000001u + const val DEFAULT_MII_NAME = "default" + + object AccountGender { + const val FEMALE: Byte = 0 + const val MALE: Byte = 1 + } + + @JvmStatic + external fun createAccount(persistentId: Int, miiName: String) + + @JvmStatic + external fun deleteAccount(persistentId: Int) + + @Keep + data class Account( + val persistentId: Int, + val miiName: String, + val birthday: Long, + val gender: Byte, + val email: String, + val country: Int, + val isValid: Boolean, + ) + + @JvmStatic + external fun getAccounts(): Array<Account> + + @JvmStatic + external fun saveAccount(account: Account) + + @Keep + data class AccountCountry( + val index: Int, + val name: String, + ) + + @JvmStatic + external fun getAccountCountries(): Array<AccountCountry> + + object OnlineAccountError { + const val NO_ACCOUNT_ID = 1 + const val NO_PASSWORD_CACHED = 2 + const val PASSWORD_CACHE_EMPTY = 3 + const val NO_PRINCIPAL_ID = 4 + } + + @Keep + sealed interface OnlineValidationError + + @Keep + class MissingOTP : OnlineValidationError + + @Keep + class CorruptedOTP : OnlineValidationError + + @Keep + class MissingSEEPROM : OnlineValidationError + + @Keep + class CorruptedSEEPROM : OnlineValidationError + + @Keep + data class MissingFile(val file: String) : OnlineValidationError + + @Keep + data class AccountError(val accountError: Int) : OnlineValidationError + + @JvmStatic + external fun getAccountValidationErrors(persistentId: Int): Array<OnlineValidationError> + + @JvmStatic + external fun isOTPPresent(): Boolean + + @JvmStatic + external fun isSEEPROMPresent(): Boolean +} 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 new file mode 100644 index 00000000..c6432364 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeActiveSettings.kt @@ -0,0 +1,21 @@ +package info.cemu.cemu.nativeinterface + +object NativeActiveSettings { + @JvmStatic + external fun initializeActiveSettings(userDataPath: String, dataPath: String, cachePath: String) + + @JvmStatic + external fun setNativeLibDir(nativeLibDir: String) + + @JvmStatic + external fun setInternalDir(internalDir: String) + + @JvmStatic + external fun getMLCPath(): String + + @JvmStatic + external fun getUserDataPath(): String + + @JvmStatic + external fun hasRequiredOnlineFiles(): Boolean +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeEmulation.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeEmulation.kt new file mode 100644 index 00000000..e31daef3 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeEmulation.kt @@ -0,0 +1,43 @@ +package info.cemu.cemu.nativeinterface + +import android.view.Surface + +object NativeEmulation { + @JvmStatic + external fun initializeEmulation() + + @JvmStatic + external fun setDPI(dpi: Float) + + @JvmStatic + external fun setSurface(surface: Surface?, isMainCanvas: Boolean) + + @JvmStatic + external fun clearSurface(isMainCanvas: Boolean) + + @JvmStatic + external fun setSurfaceSize(width: Int, height: Int, isMainCanvas: Boolean) + + @JvmStatic + external fun initializeRenderer(surface: Surface?) + + object StartGameStatusCode { + const val SUCCESSFUL: Int = 0 + const val ERROR_GAME_BASE_FILES_NOT_FOUND: Int = 1 + const val ERROR_NO_DISC_KEY: Int = 2 + const val ERROR_NO_TITLE_TIK: Int = 3 + const val ERROR_UNKNOWN: Int = 4 + } + + @JvmStatic + external fun startGame(launchPath: String?): Int + + @JvmStatic + external fun setReplaceTVWithPadView(swapped: Boolean) + + @JvmStatic + external fun recreateRenderSurface(isMainCanvas: Boolean) + + @JvmStatic + external fun supportsLoadingCustomDriver(): Boolean +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeException.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeException.kt new file mode 100644 index 00000000..a1982492 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeException.kt @@ -0,0 +1,3 @@ +package info.cemu.cemu.nativeinterface + +class NativeException(message: String) : RuntimeException(message) 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 new file mode 100644 index 00000000..47c6aac6 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeFiles.kt @@ -0,0 +1,124 @@ +@file:Suppress("unused") + +package info.cemu.cemu.nativeinterface + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Log +import androidx.annotation.Keep +import androidx.core.net.toUri + +private const val PATH_SEPARATOR_ENCODED = "%2F" +private const val PATH_SEPARATOR_DECODED = "/" +private const val COLON_ENCODED = "%3A" +private const val MODE = "r" + +fun Uri.toNativePath(): String { + val uriPath = toString() + val delimiterPos = uriPath.lastIndexOf(COLON_ENCODED) + if (delimiterPos == -1) { + return uriPath + } + return uriPath.substring(0, delimiterPos) + uriPath.substring(delimiterPos).replace( + PATH_SEPARATOR_ENCODED, PATH_SEPARATOR_DECODED + ) +} + +fun String.fromNativePath(): Uri { + val delimiterPos = lastIndexOf(COLON_ENCODED) + if (delimiterPos == -1) { + return toUri() + } + + return (substring(0, delimiterPos) + substring(delimiterPos).replace( + PATH_SEPARATOR_DECODED, PATH_SEPARATOR_ENCODED + )).toUri() +} + +object NativeFiles { + private lateinit var contentResolver: ContentResolver + + fun initialize(contentResolver: ContentResolver) { + this.contentResolver = contentResolver + } + + @Keep + @JvmStatic + fun openContentUri(uri: String): Int { + try { + val parcelFileDescriptor = + contentResolver.openFileDescriptor( + uri.fromNativePath(), MODE + ) + if (parcelFileDescriptor != null) { + val fd = parcelFileDescriptor.detachFd() + parcelFileDescriptor.close() + return fd + } + } catch (e: Exception) { + Log.e("NativeFiles", "Cannot open content uri, error: ${e.message}") + } + return -1 + } + + @Keep + @JvmStatic + fun listFiles(uri: String): Array<String?> { + val files = ArrayList<String>() + val directoryUri = uri.fromNativePath() + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + directoryUri, + DocumentsContract.getDocumentId(directoryUri) + ) + try { + contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, + null, + null + ).use { cursor -> + while (cursor != null && cursor.moveToNext()) { + val documentId = cursor.getString(0) + val documentUri = + DocumentsContract.buildDocumentUriUsingTree(directoryUri, documentId) + files.add(documentUri.toNativePath()) + } + } + } catch (e: Exception) { + Log.e("NativeFiles", "Cannot list files: ${e.message}") + } + var filesArray = arrayOfNulls<String>(files.size) + filesArray = files.toArray(filesArray) + return filesArray + } + + @Keep + @JvmStatic + fun isDirectory(uri: String): Boolean { + val mimeType = contentResolver.getType( + uri.fromNativePath() + ) + return DocumentsContract.Document.MIME_TYPE_DIR == mimeType + } + + @Keep + @JvmStatic + fun isFile(uri: String): Boolean { + return !isDirectory(uri) + } + + @Keep + @JvmStatic + fun exists(uri: String): Boolean { + try { + contentResolver.query(uri.fromNativePath(), null, null, null, null).use { cursor -> + return cursor != null && cursor.moveToFirst() + } + } catch (e: Exception) { + Log.e("NativeFiles", "Failed checking if file exists: ${e.message}") + return false + } + } +} 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 new file mode 100644 index 00000000..777a334e --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGameTitles.kt @@ -0,0 +1,251 @@ +package info.cemu.cemu.nativeinterface + +import android.graphics.Bitmap +import androidx.annotation.Keep +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap + +object NativeGameTitles { + object ConsoleRegion { + const val JPN: Int = 0x1 + const val USA: Int = 0x2 + const val EUR: Int = 0x4 + const val AUS_DEPR: Int = 0x8 + const val CHN: Int = 0x10 + const val KOR: Int = 0x20 + const val TWN: Int = 0x40 + const val AUTO: Int = 0xFF + } + + + @JvmStatic + external fun isLoadingSharedLibrariesForTitleEnabled(gameTitleId: Long): Boolean + + @JvmStatic + external fun setLoadingSharedLibrariesForTitleEnabled(gameTitleId: Long, enabled: Boolean) + + object CPUMode { + const val SINGLECOREINTERPRETER: Int = 0 + const val SINGLECORERECOMPILER: Int = 1 + const val MULTICORERECOMPILER: Int = 3 + const val AUTO: Int = 4 + } + + + @JvmStatic + external fun getCpuModeForTitle(gameTitleId: Long): Int + + @JvmStatic + external fun setCpuModeForTitle(gameTitleId: Long, cpuMode: Int) + + val THREAD_QUANTUM_VALUES: IntArray = intArrayOf( + 20000, + 45000, + 60000, + 80000, + 100000, + ) + + @JvmStatic + external fun getThreadQuantumForTitle(gameTitleId: Long): Int + + @JvmStatic + external fun setThreadQuantumForTitle(gameTitleId: Long, threadQuantum: Int) + + @JvmStatic + external fun isShaderMultiplicationAccuracyForTitleEnabled(gameTitleId: Long): Boolean + + @JvmStatic + external fun setShaderMultiplicationAccuracyForTitleEnabled(gameTitleId: Long, enabled: Boolean) + + @JvmStatic + external fun titleHasShaderCacheFiles(gameTitleId: Long): Boolean + + @JvmStatic + external fun removeShaderCacheFilesForTitle(gameTitleId: Long) + + @JvmStatic + external fun setGameTitleFavorite(gameTitleId: Long, isFavorite: Boolean) + + @JvmStatic + external fun setGameTitleLoadedCallback(gameTitleLoadedCallback: GameTitleLoadedCallback?) + + @JvmStatic + external fun reloadGameTitles() + + @JvmStatic + external fun getInstalledGamesTitleIds(): List<Long> + + @Keep + data class Game( + val titleId: Long, + val path: String?, + val name: String?, + val version: Short, + val dlc: Short, + val region: Int, + val lastPlayedYear: Short, + val lastPlayedMonth: Short, + val lastPlayedDay: Short, + val minutesPlayed: Int, + val isFavorite: Boolean, + private val _icon: Bitmap?, + ) : Comparable<Game> { + override fun compareTo(other: Game): Int { + if (titleId == other.titleId) { + return 0 + } + if (isFavorite && !other.isFavorite) { + return -1 + } + if (!isFavorite && other.isFavorite) { + return 1 + } + if (name == other.name) { + return titleId.compareTo(other.titleId) + } + return name?.compareTo(other.name ?: "") ?: 0 + } + + val icon: ImageBitmap? = _icon?.asImageBitmap() + + fun isValid(): Boolean { + return !path.isNullOrEmpty() && !name.isNullOrEmpty() + } + } + + @Keep + 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?) + + object TitleType { + const val UNKNOWN: Int = 0xFF + const val BASE_TITLE: Int = 0x00 + const val BASE_TITLE_DEMO: Int = 0x02 + const val BASE_TITLE_UPDATE: Int = 0x0E + const val HOMEBREW: Int = 0x0F + const val AOC: Int = 0x0C + const val SYSTEM_TITLE: Int = 0x10 + const val SYSTEM_DATA: Int = 0x1B + const val SYSTEM_OVERLAY_TITLE: Int = 0x30 + } + + object TitleDataFormat { + const val HOST_FS: Int = 1 + const val WUD: Int = 2 + const val WIIU_ARCHIVE: Int = 3 + const val NUS: Int = 4 + const val WUHB: Int = 5 + const val 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) + + enum class CompressResult { + FINISHED, + ERROR, + } + + fun compressQueuedTitle(fd: Int, callback: (CompressResult) -> Unit) = + compressQueuedTitle(fd, object : TitleCompressCallbacks { + override fun onFinished() = callback(CompressResult.FINISHED) + override fun onError() = callback(CompressResult.ERROR) + }) + + @JvmStatic + external fun getCurrentProgressForCompression(): Long + + @JvmStatic + external fun cancelTitleCompression() +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGraphicPacks.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGraphicPacks.kt new file mode 100644 index 00000000..eb2d7255 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeGraphicPacks.kt @@ -0,0 +1,90 @@ +package info.cemu.cemu.nativeinterface + +import androidx.annotation.Keep +import java.util.Objects + +object NativeGraphicPacks { + @JvmStatic + external fun getGraphicPackBasicInfos(): List<GraphicPackBasicInfo> + + @JvmStatic + external fun refreshGraphicPacks() + + @JvmStatic + external fun getGraphicPack(id: Long): GraphicPack? + + @JvmStatic + external fun setGraphicPackActive(id: Long, active: Boolean) + + @JvmStatic + external fun setGraphicPackActivePreset(id: Long, category: String?, preset: String?) + + @JvmStatic + external fun getGraphicPackPresets(id: Long): ArrayList<GraphicPackPreset> + + @Keep + data class GraphicPackBasicInfo( + val id: Long, + val virtualPath: String, + val enabled: Boolean, + val titleIds: ArrayList<Long> + ) + + @Keep + class GraphicPackPreset( + private val graphicPackId: Long, + val category: String?, + val presets: ArrayList<String>, + private var _activePreset: String + ) { + override fun hashCode(): Int { + return Objects.hash(graphicPackId, category, presets, _activePreset) + } + + override fun equals(other: Any?): Boolean { + if (other == null) { + return false + } + if (other === this) { + return true + } + if (other is GraphicPackPreset) { + return this.hashCode() == other.hashCode() + } + return false + } + + var activePreset: String + get() = _activePreset + set(value) { + require(presets.any { it == value }) { "Trying to set an invalid preset: $value" } + setGraphicPackActivePreset(graphicPackId, category, value) + _activePreset = value + } + } + + @Keep + class GraphicPack( + val id: Long, + private var active: Boolean, + val name: String, + val description: String, + private var _presets: ArrayList<GraphicPackPreset> + ) { + fun isActive(): Boolean { + return active + } + + val presets: List<GraphicPackPreset> + get() = _presets + + fun reloadPresets() { + _presets = getGraphicPackPresets(id) + } + + fun setActive(active: Boolean) { + this.active = active + setGraphicPackActive(id, active) + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeInput.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeInput.kt new file mode 100644 index 00000000..df53384d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeInput.kt @@ -0,0 +1,223 @@ +package info.cemu.cemu.nativeinterface + +object NativeInput { + object VPADButton { + const val A: Int = 1 + const val B: Int = 2 + const val X: Int = 3 + const val Y: Int = 4 + const val L: Int = 5 + const val R: Int = 6 + const val ZL: Int = 7 + const val ZR: Int = 8 + const val PLUS: Int = 9 + const val MINUS: Int = 10 + const val UP: Int = 11 + const val DOWN: Int = 12 + const val LEFT: Int = 13 + const val RIGHT: Int = 14 + const val STICKL: Int = 15 + const val STICKR: Int = 16 + const val STICKL_UP: Int = 17 + const val STICKL_DOWN: Int = 18 + const val STICKL_LEFT: Int = 19 + const val STICKL_RIGHT: Int = 20 + const val STICKR_UP: Int = 21 + const val STICKR_DOWN: Int = 22 + const val STICKR_LEFT: Int = 23 + const val STICKR_RIGHT: Int = 24 + const val MIC: Int = 25 + const val SCREEN: Int = 26 + const val HOME: Int = 27 + } + + object ProButton { + const val A: Int = 1 + const val B: Int = 2 + const val X: Int = 3 + const val Y: Int = 4 + const val L: Int = 5 + const val R: Int = 6 + const val ZL: Int = 7 + const val ZR: Int = 8 + const val PLUS: Int = 9 + const val MINUS: Int = 10 + const val HOME: Int = 11 + const val UP: Int = 12 + const val DOWN: Int = 13 + const val LEFT: Int = 14 + const val RIGHT: Int = 15 + const val STICKL: Int = 16 + const val STICKR: Int = 17 + const val STICKL_UP: Int = 18 + const val STICKL_DOWN: Int = 19 + const val STICKL_LEFT: Int = 20 + const val STICKL_RIGHT: Int = 21 + const val STICKR_UP: Int = 22 + const val STICKR_DOWN: Int = 23 + const val STICKR_LEFT: Int = 24 + const val STICKR_RIGHT: Int = 25 + } + + object ClassicButton { + const val A: Int = 1 + const val B: Int = 2 + const val X: Int = 3 + const val Y: Int = 4 + const val L: Int = 5 + const val R: Int = 6 + const val ZL: Int = 7 + const val ZR: Int = 8 + const val PLUS: Int = 9 + const val MINUS: Int = 10 + const val HOME: Int = 11 + const val UP: Int = 12 + const val DOWN: Int = 13 + const val LEFT: Int = 14 + const val RIGHT: Int = 15 + const val STICKL_UP: Int = 16 + const val STICKL_DOWN: Int = 17 + const val STICKL_LEFT: Int = 18 + const val STICKL_RIGHT: Int = 19 + const val STICKR_UP: Int = 20 + const val STICKR_DOWN: Int = 21 + const val STICKR_LEFT: Int = 22 + const val STICKR_RIGHT: Int = 23 + } + + + object WiimoteButton { + const val A: Int = 1 + const val B: Int = 2 + const val ONE: Int = 3 + const val TWO: Int = 4 + const val NUNCHUCK_Z: Int = 5 + const val NUNCHUCK_C: Int = 6 + const val PLUS: Int = 7 + const val MINUS: Int = 8 + const val UP: Int = 9 + const val DOWN: Int = 10 + const val LEFT: Int = 11 + const val RIGHT: Int = 12 + const val NUNCHUCK_UP: Int = 13 + const val NUNCHUCK_DOWN: Int = 14 + const val NUNCHUCK_LEFT: Int = 15 + const val NUNCHUCK_RIGHT: Int = 16 + const val HOME: Int = 17 + } + + object EmulatedControllerType { + const val VPAD: Int = 0 + const val PRO: Int = 1 + const val CLASSIC: Int = 2 + const val WIIMOTE: Int = 3 + const val DISABLED: Int = -1 + } + + object Axis { + const val DPAD_UP: Int = 34 + const val DPAD_DOWN: Int = 35 + const val DPAD_LEFT: Int = 36 + const val DPAD_RIGHT: Int = 37 + const val X_POS: Int = 38 + const val Y_POS: Int = 39 + const val ROTATION_X_POS: Int = 40 + const val ROTATION_Y_POS: Int = 41 + const val TRIGGER_X_POS: Int = 42 + const val TRIGGER_Y_POS: Int = 43 + const val X_NEG: Int = 44 + const val Y_NEG: Int = 45 + const val ROTATION_X_NEG: Int = 46 + const val ROTATION_Y_NEG: Int = 47 + } + + const val MAX_CONTROLLERS: Int = 8 + const val MAX_VPAD_CONTROLLERS: Int = 2 + const val MAX_WPAD_CONTROLLERS: Int = 7 + + @JvmStatic + external fun onNativeKey( + deviceDescriptor: String?, + deviceName: String?, + key: Int, + isPressed: Boolean, + ) + + @JvmStatic + external fun onNativeAxis( + deviceDescriptor: String?, + deviceName: String?, + axis: Int, + value: Float, + ) + + @JvmStatic + external fun setControllerType(index: Int, emulatedControllerType: Int) + + @JvmStatic + external fun isControllerDisabled(index: Int): Boolean + + @JvmStatic + external fun getControllerType(index: Int): Int + + @JvmStatic + val WPADControllersCount: Int + external get + + @JvmStatic + val VPADControllersCount: Int + external get + + @JvmStatic + external fun setVPADScreenToggle(index: Int, enabled: Boolean) + + @JvmStatic + external fun getVPADScreenToggle(index: Int): Boolean + + @JvmStatic + external fun setControllerMapping( + deviceDescriptor: String?, + deviceName: String?, + index: Int, + mappingId: Int, + buttonId: Int, + ) + + @JvmStatic + external fun clearControllerMapping(index: Int, mappingId: Int) + + @JvmStatic + external fun getControllerMapping(index: Int, mappingId: Int): String + + @JvmStatic + external fun getControllerMappings(index: Int): Map<Int, String> + + @JvmStatic + external fun onTouchDown(x: Int, y: Int, isTV: Boolean) + + @JvmStatic + external fun onTouchMove(x: Int, y: Int, isTV: Boolean) + + @JvmStatic + external fun onTouchUp(x: Int, y: Int, isTV: Boolean) + + @JvmStatic + external fun onMotion( + timestamp: Long, + gyroX: Float, + gyroY: Float, + gyroZ: Float, + accelX: Float, + accelY: Float, + accelZ: Float, + ) + + @JvmStatic + external fun setMotionEnabled(motionEnabled: Boolean) + + @JvmStatic + external fun onOverlayButton(controllerIndex: Int, mappingId: Int, value: Boolean) + + @JvmStatic + external fun onOverlayAxis(controllerIndex: Int, mappingId: Int, value: Float) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLocalization.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLocalization.kt new file mode 100644 index 00000000..daa4c2fb --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLocalization.kt @@ -0,0 +1,6 @@ +package info.cemu.cemu.nativeinterface + +object NativeLocalization { + @JvmStatic + external fun setTranslations(translations: Map<String, String>) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLogging.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLogging.kt new file mode 100644 index 00000000..e3f435c9 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeLogging.kt @@ -0,0 +1,9 @@ +package info.cemu.cemu.nativeinterface + +object NativeLogging { + @JvmStatic + external fun log(message: String?) + + @JvmStatic + external fun crashLog(stacktrace: String?) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSettings.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSettings.kt new file mode 100644 index 00000000..2cf78594 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSettings.kt @@ -0,0 +1,247 @@ +package info.cemu.cemu.nativeinterface + +object NativeSettings { + @JvmStatic + external fun saveSettings() + + @JvmStatic + external fun addGamesPath(uri: String?) + + @JvmStatic + external fun removeGamesPath(uri: String?) + + @JvmStatic + external fun getGamesPaths(): ArrayList<String> + + @JvmStatic + external fun getAsyncShaderCompile(): Boolean + + @JvmStatic + external fun setAsyncShaderCompile(value: Boolean) + + object VSyncMode { + const val OFF: Int = 0 + const val DOUBLE_BUFFERING: Int = 1 + const val TRIPLE_BUFFERING: Int = 2 + } + + @JvmStatic + external fun getVsyncMode(): Int + + @JvmStatic + external fun setVsyncMode(value: Int) + + object FullscreenScaling { + const val KEEP_ASPECT_RATIO: Int = 0 + const val STRETCH: Int = 1 + } + + @JvmStatic + external fun getFullscreenScaling(): Int + + @JvmStatic + external fun setFullscreenScaling(value: Int) + + object ScalingFilter { + const val BILINEAR_FILTER: Int = 0 + const val BICUBIC_FILTER: Int = 1 + const val BICUBIC_HERMITE_FILTER: Int = 2 + const val NEAREST_NEIGHBOR_FILTER: Int = 3 + } + + @JvmStatic + external fun getUpscalingFilter(): Int + + @JvmStatic + external fun setUpscalingFilter(value: Int) + + @JvmStatic + external fun getDownscalingFilter(): Int + + @JvmStatic + external fun setDownscalingFilter(value: Int) + + @JvmStatic + external fun getAccurateBarriers(): Boolean + + @JvmStatic + external fun setAccurateBarriers(value: Boolean) + + @JvmStatic + external fun getAudioDeviceEnabled(tv: Boolean): Boolean + + @JvmStatic + external fun setAudioDeviceEnabled(enabled: Boolean, tv: Boolean) + + object AudioChannels { + const val MONO: Int = 0 + const val STEREO: Int = 1 + const val SURROUND: Int = 2 + } + + @JvmStatic + external fun setAudioDeviceChannels(channels: Int, tv: Boolean) + + @JvmStatic + external fun getAudioDeviceChannels(tv: Boolean): Int + + const val AUDIO_MIN_VOLUME: Int = 0 + const val AUDIO_MAX_VOLUME: Int = 100 + + @JvmStatic + external fun setAudioDeviceVolume(volume: Int, tv: Boolean) + + @JvmStatic + external fun getAudioDeviceVolume(tv: Boolean): Int + + const val AUDIO_LATENCY_MS_MAX: Int = 276 + + @JvmStatic + external fun getAudioLatency(): Int + + @JvmStatic + external fun setAudioLatency(value: Int) + + object OverlayScreenPosition { + const val DISABLED: Int = 0 + const val TOP_LEFT: Int = 1 + const val TOP_CENTER: Int = 2 + const val TOP_RIGHT: Int = 3 + const val BOTTOM_LEFT: Int = 4 + const val BOTTOM_CENTER: Int = 5 + const val BOTTOM_RIGHT: Int = 6 + } + + @JvmStatic + external fun getOverlayPosition(): Int + + @JvmStatic + external fun setOverlayPosition(value: Int) + + const val OVERLAY_TEXT_SCALE_MIN: Int = 50 + const val OVERLAY_TEXT_SCALE_MAX: Int = 300 + + @JvmStatic + external fun getOverlayTextScalePercentage(): Int + + @JvmStatic + external fun setOverlayTextScalePercentage(value: Int) + + @JvmStatic + external fun isOverlayFPSEnabled(): Boolean + + @JvmStatic + external fun setOverlayFPSEnabled(value: Boolean) + + @JvmStatic + external fun isOverlayDrawCallsPerFrameEnabled(): Boolean + + @JvmStatic + external fun setOverlayDrawCallsPerFrameEnabled(value: Boolean) + + @JvmStatic + external fun isOverlayCPUUsageEnabled(): Boolean + + @JvmStatic + external fun setOverlayCPUUsageEnabled(value: Boolean) + + @JvmStatic + external fun isOverlayRAMUsageEnabled(): Boolean + + @JvmStatic + external fun setOverlayRAMUsageEnabled(value: Boolean) + + @JvmStatic + external fun isOverlayDebugEnabled(): Boolean + + @JvmStatic + external fun setOverlayDebugEnabled(value: Boolean) + + @JvmStatic + external fun getNotificationsPosition(): Int + + @JvmStatic + external fun setNotificationsPosition(value: Int) + + @JvmStatic + external fun getNotificationsTextScalePercentage(): Int + + @JvmStatic + external fun setNotificationsTextScalePercentage(value: Int) + + @JvmStatic + external fun isNotificationControllerProfilesEnabled(): Boolean + + @JvmStatic + external fun setNotificationControllerProfilesEnabled(value: Boolean) + + @JvmStatic + external fun isNotificationShaderCompilerEnabled(): Boolean + + @JvmStatic + external fun setNotificationShaderCompilerEnabled(value: Boolean) + + @JvmStatic + external fun isNotificationFriendListEnabled(): Boolean + + @JvmStatic + external fun setNotificationFriendListEnabled(value: Boolean) + + object ConsoleLanguage { + const val JAPANESE: Int = 0 + const val ENGLISH: Int = 1 + const val FRENCH: Int = 2 + const val GERMAN: Int = 3 + const val ITALIAN: Int = 4 + const val SPANISH: Int = 5 + const val CHINESE: Int = 6 + const val KOREAN: Int = 7 + const val DUTCH: Int = 8 + const val PORTUGUESE: Int = 9 + const val RUSSIAN: Int = 10 + const val TAIWANESE: Int = 11 + } + + @JvmStatic + external fun getConsoleLanguage(): Int + + @JvmStatic + external fun setConsoleLanguage(value: Int) + + /** + * @return the selected driver directory path. If it's null, then the default system driver will be used. + */ + @JvmStatic + external fun getCustomDriverPath(): String? + + /** + * Sets the selected driver directory [path]. To use the default system driver, pass a null [path]. + */ + @JvmStatic + external fun setCustomDriverPath(path: String?) + + object NetworkService { + const val OFFLINE = 0 + const val NINTENDO = 1 + const val PRETENDO = 2 + const val CUSTOM = 3 + } + + @JvmStatic + external fun getAccountNetworkService(persistentId: Int): Int + + + @JvmStatic + external fun setAccountNetworkService(persistentId: Int, networkService: Int) + + + @JvmStatic + external fun getAccountPersistentId(): Int + + + @JvmStatic + external fun setAccountPersistentId(persistentId: Int) + + @JvmStatic + external fun hasCustomNetworkConfiguration(): Boolean +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSwkbd.kt b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSwkbd.kt new file mode 100644 index 00000000..0611194d --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/nativeinterface/NativeSwkbd.kt @@ -0,0 +1,15 @@ +package info.cemu.cemu.nativeinterface + +object NativeSwkbd { + @JvmStatic + external fun initializeSwkbd() + + @JvmStatic + external fun setCurrentInputText(text: String?) + + @JvmStatic + external fun onTextChanged(text: String?) + + @JvmStatic + external fun onFinishedInputEdit() +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/provider/DocumentsProvider.kt b/src/android/app/src/main/java/info/cemu/cemu/provider/DocumentsProvider.kt new file mode 100644 index 00000000..8a76cd3f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/provider/DocumentsProvider.kt @@ -0,0 +1,361 @@ +// Based on: +// Skyline +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/) +package info.cemu.cemu.provider + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import info.cemu.cemu.BuildConfig +import info.cemu.cemu.R +import info.cemu.cemu.common.android.context.internalFolder +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.util.Objects + +class DocumentsProvider : DocumentsProvider() { + private val baseDirectory: File by lazy { + requireContext().internalFolder() + } + + private val applicationName: String by lazy { + var context = requireContext().applicationContext + context.applicationInfo.loadLabel(context.packageManager).toString() + } + + override fun onCreate(): Boolean { + return true + } + + override fun queryRoots(projection: Array<String>?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + cursor.newRow().add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + .add(DocumentsContract.Root.COLUMN_SUMMARY, null) + .add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + .add(DocumentsContract.Root.COLUMN_TITLE, applicationName) + .add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + .add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + .add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) + .add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher) + return cursor + } + + @Throws(FileNotFoundException::class) + override fun queryDocument(documentId: String, projection: Array<String>?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + includeFile(cursor, documentId, null) + return cursor + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + if (parentDocumentId == null || documentId == null) { + return false + } + return documentId.startsWith(parentDocumentId) + } + + @Throws(FileNotFoundException::class) + override fun createDocument( + parentDocumentId: String, + mimeType: String, + displayName: String, + ): String { + val parentFile = getFile(parentDocumentId) + val newFile = resolveWithoutConflict(parentFile, displayName) + + if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { + if (!newFile.mkdir()) { + throw FileNotFoundException("Failed to create directory") + } + } else { + try { + if (!newFile.createNewFile()) { + throw FileNotFoundException("Failed to create file") + } + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + return getDocumentId(newFile) + } + + @Throws(FileNotFoundException::class) + override fun deleteDocument(documentId: String) { + val file = getFile(documentId) + if (file.isDirectory) { + deleteFolder(file) + return + } + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID $documentId") + } + } + + @Throws(FileNotFoundException::class) + private fun deleteFolder(dirFile: File) { + if (!dirFile.isDirectory) { + return + } + val files = dirFile.listFiles() ?: return + for (file in files) { + if (file.isDirectory) { + deleteFolder(file) + continue + } + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete file ${file.path}") + } + } + if (!dirFile.delete()) { + throw FileNotFoundException("Couldn't delete file ${dirFile.path}") + } + } + + @Throws(FileNotFoundException::class) + override fun removeDocument(documentId: String, parentDocumentId: String) { + val parent = getFile(parentDocumentId) + val file = getFile(documentId) + + if (!(parent == file || file.parentFile == null || file.parentFile == parent)) { + throw FileNotFoundException("Couldn't delete document with ID $documentId") + } + if (file.isDirectory) { + deleteFolder(file) + return + } + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID $documentId") + } + } + + @Throws(FileNotFoundException::class) + override fun renameDocument(documentId: String, displayName: String?): String { + if (displayName == null) { + throw FileNotFoundException("Couldn't rename document $documentId as the new name is null") + } + + val sourceFile = getFile(documentId) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") + val destFile = resolve(sourceParentFile, displayName) + + try { + if (!sourceFile.renameTo(destFile)) { + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") + } + } catch (exception: Exception) { + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to 'destFile.name': ${exception.message}") + } + + return getDocumentId(destFile) + } + + @Throws(FileNotFoundException::class) + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String { + val parent = getFile(targetParentDocumentId) + val oldFile = getFile(sourceDocumentId) + val newFile = resolveWithoutConflict(parent, oldFile.name) + + try { + if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) { + throw IOException("Couldn't create new file") + } + FileInputStream(oldFile).use { inputStream -> + FileOutputStream(newFile).use { outputStream -> + val b = ByteArray(1024) + var len: Int + while ((inputStream.read(b, 0, 1024).also { len = it }) > 0) { + outputStream.write(b, 0, len) + } + } + } + } catch (exception: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${exception.message}") + } + return getDocumentId(newFile) + } + + @Throws(FileNotFoundException::class) + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String, + ): String { + try { + val newDocumentId = + copyDocument(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (notFoundException: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId' ${notFoundException.message}") + } + } + + @Throws(FileNotFoundException::class) + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array<String>?, + sortOrder: String?, + ): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + val parent = getFile(parentDocumentId) + val files = parent.listFiles() ?: return cursor + for (file in files) { + includeFile(cursor, null, file) + } + return cursor + } + + @Throws(FileNotFoundException::class) + override fun openDocument( + documentId: String, + mode: String, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + val file = getFile(documentId) + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } + + private fun resolve(file: File, other: String): File { + return file.toPath().resolve(other).toFile() + } + + @Throws(FileNotFoundException::class) + private fun copyDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String, + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") + } + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + private fun resolveWithoutConflict(originalFile: File, name: String): File { + var file = resolve(originalFile, name) + if (!file.exists()) { + return file + } + + // Makes sure two files don't have the same name by adding a number to the end + var noConflictId = 1 + val periodIndex = name.lastIndexOf('.') + var extension = "" + var baseName = name + if (periodIndex != -1) { + baseName = name.substring(0, periodIndex) + extension = name.substring(periodIndex) + } + while (file.exists()) { + val newFileName = "$baseName ($noConflictId)$extension" + noConflictId++ + file = file.toPath().resolve(newFileName).toFile() + } + return file + } + + @Throws(FileNotFoundException::class) + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?) { + val localDocumentId = documentId ?: getDocumentId(file!!) + val localFile = file ?: getFile(documentId) + var flags = 0 + if (localFile.isDirectory && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else if (localFile.canWrite()) { + flags = (DocumentsContract.Document.FLAG_SUPPORTS_WRITE + or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + or DocumentsContract.Document.FLAG_SUPPORTS_COPY + or DocumentsContract.Document.FLAG_SUPPORTS_RENAME) + } + flags = (flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE) + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (localFile == baseDirectory) applicationName else localFile.name + ) + add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + if (localFile == baseDirectory) { + add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher) + } + } + } + + private fun getTypeForFile(file: File): String { + if (file.isDirectory) { + return DocumentsContract.Document.MIME_TYPE_DIR + } + return getTypeForName(file.name) + } + + private fun getTypeForName(name: String): String { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) { + return mime + } + } + return "application/octect-stream" + } + + @Throws(FileNotFoundException::class) + private fun getFile(documentId: String?): File { + Objects.requireNonNull(documentId) + if (documentId!!.startsWith(ROOT_ID)) { + val file = resolve(baseDirectory, documentId.substring(ROOT_ID.length + 1)) + if (!file.exists()) { + throw FileNotFoundException("${file.absolutePath} $documentId not found") + } + return file + } else { + throw FileNotFoundException("$documentId is not in any known root") + } + } + + private fun getDocumentId(file: File): String { + return ROOT_ID + "/" + baseDirectory.toPath().relativize(file.toPath()).toString() + } + + companion object { + const val ROOT_ID: String = "root" + const val AUTHORITY: String = "${BuildConfig.APPLICATION_ID}.provider" + private val DEFAULT_ROOT_PROJECTION = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsHomeScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsHomeScreen.kt new file mode 100644 index 00000000..807cc114 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsHomeScreen.kt @@ -0,0 +1,49 @@ +package info.cemu.cemu.settings + +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.dropUnlessResumed +import info.cemu.cemu.common.ui.components.Button +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.localization.tr + +data class SettingsHomeScreenActions( + val goToGeneralSettings: () -> Unit, + val goToInputSettings: () -> Unit, + val goToGraphicsSettings: () -> Unit, + val goToAudioSettings: () -> Unit, + val goToAccountSettings: () -> Unit, + val goToOverlaySettings: () -> Unit, +) + +@Composable +fun SettingsHomeScreen(navigateBack: () -> Unit, actions: SettingsHomeScreenActions) { + ScreenContent( + appBarText = tr("Settings"), + navigateBack = navigateBack, + ) { + Button( + label = tr("General settings"), + onClick = dropUnlessResumed(block = actions.goToGeneralSettings) + ) + Button( + label = tr("Input settings"), + onClick = dropUnlessResumed(block = actions.goToInputSettings) + ) + Button( + label = tr("Graphics settings"), + onClick = dropUnlessResumed(block = actions.goToGraphicsSettings) + ) + Button( + label = tr("Audio settings"), + onClick = dropUnlessResumed(block = actions.goToAudioSettings) + ) + Button( + label = tr("Overlay settings"), + onClick = dropUnlessResumed(block = actions.goToOverlaySettings) + ) + Button( + label = tr("Account settings"), + onClick = dropUnlessResumed(block = actions.goToAccountSettings) + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsNavigation.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsNavigation.kt new file mode 100644 index 00000000..cc91755a --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/SettingsNavigation.kt @@ -0,0 +1,155 @@ +package info.cemu.cemu.settings + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.toRoute +import info.cemu.cemu.settings.account.AccountSettingsScreen +import info.cemu.cemu.settings.audio.AudioSettingsScreen +import info.cemu.cemu.settings.customdrivers.CustomDriversScreen +import info.cemu.cemu.settings.gamespath.GamePathsScreen +import info.cemu.cemu.settings.general.GeneralSettingsScreen +import info.cemu.cemu.settings.graphics.GraphicsSettingsScreen +import info.cemu.cemu.settings.input.ControllerInputSettingsScreen +import info.cemu.cemu.settings.input.InputSettingsScreen +import info.cemu.cemu.settings.input.InputSettingsScreenActions +import info.cemu.cemu.settings.inputoverlay.InputOverlaySettingsScreen +import info.cemu.cemu.settings.overlay.OverlaySettingsScreen +import kotlinx.serialization.Serializable + +@Serializable +object SettingsRoute + +private object SettingsRoutes { + @Serializable + object GeneralSettings + + @Serializable + object GeneralSettingsScreenRoute + + @Serializable + object InputSettingsRoute + + @Serializable + object SettingsHomeScreenRoute + + @Serializable + object AudioSettingsScreenRoute + + @Serializable + object GraphicsSettingsScreenRoute + + @Serializable + object CustomDriversScreenRoute + + @Serializable + object GamePathsScreenRoute + + @Serializable + object OverlaySettingsScreenRoute + + @Serializable + object InputSettingsScreenRoute + + @Serializable + data class ControllerInputSettingsScreenRoute(val index: Int) + + @Serializable + object InputOverlaySettingsScreenRoute + + @Serializable + object AccountSettingsScreenRoute +} + +fun NavGraphBuilder.settingsNavigation(navController: NavHostController) { + navigation<SettingsRoute>(startDestination = SettingsRoutes.SettingsHomeScreenRoute) { + composable<SettingsRoutes.SettingsHomeScreenRoute> { + SettingsHomeScreen( + navigateBack = { navController.popBackStack() }, + actions = SettingsHomeScreenActions( + goToGeneralSettings = { navController.navigate(SettingsRoutes.GeneralSettings) }, + goToInputSettings = { navController.navigate(SettingsRoutes.InputSettingsRoute) }, + goToGraphicsSettings = { navController.navigate(SettingsRoutes.GraphicsSettingsScreenRoute) }, + goToAudioSettings = { navController.navigate(SettingsRoutes.AudioSettingsScreenRoute) }, + goToOverlaySettings = { navController.navigate(SettingsRoutes.OverlaySettingsScreenRoute) }, + goToAccountSettings = { navController.navigate(SettingsRoutes.AccountSettingsScreenRoute) } + ) + ) + } + composable<SettingsRoutes.AudioSettingsScreenRoute> { + AudioSettingsScreen( + navigateBack = { navController.popBackStack() }, + ) + } + composable<SettingsRoutes.GraphicsSettingsScreenRoute> { + GraphicsSettingsScreen( + navigateBack = { navController.popBackStack() }, + goToCustomDriversSettings = { + navController.navigate(SettingsRoutes.CustomDriversScreenRoute) + } + ) + } + composable<SettingsRoutes.CustomDriversScreenRoute> { + CustomDriversScreen( + navigateBack = { navController.popBackStack() }, + ) + } + composable<SettingsRoutes.OverlaySettingsScreenRoute> { + OverlaySettingsScreen( + navigateBack = { navController.popBackStack() }, + ) + } + navigation<SettingsRoutes.InputSettingsRoute>(startDestination = SettingsRoutes.InputSettingsScreenRoute) { + composable<SettingsRoutes.ControllerInputSettingsScreenRoute> { navBackStackEntry -> + val controllerIndex = + navBackStackEntry.toRoute<SettingsRoutes.ControllerInputSettingsScreenRoute>().index + ControllerInputSettingsScreen( + navigateBack = { navController.popBackStack() }, + controllerIndex = controllerIndex, + ) + } + composable<SettingsRoutes.InputOverlaySettingsScreenRoute> { + InputOverlaySettingsScreen( + navigateBack = { navController.popBackStack() } + ) + } + composable<SettingsRoutes.InputSettingsScreenRoute> { + InputSettingsScreen( + navigateBack = { navController.popBackStack() }, + actions = InputSettingsScreenActions( + goToInputOverlaySettings = { + navController.navigate(SettingsRoutes.InputOverlaySettingsScreenRoute) + }, + goToControllerSettings = { controllerIndex -> + navController.navigate( + SettingsRoutes.ControllerInputSettingsScreenRoute( + controllerIndex + ) + ) + }, + ) + ) + } + } + navigation<SettingsRoutes.GeneralSettings>(startDestination = SettingsRoutes.GeneralSettingsScreenRoute) { + composable<SettingsRoutes.GeneralSettingsScreenRoute> { + GeneralSettingsScreen( + navigateBack = { navController.popBackStack() }, + goToGamePathsSettings = { navController.navigate(SettingsRoutes.GamePathsScreenRoute) } + ) + } + composable<SettingsRoutes.GamePathsScreenRoute> { + GamePathsScreen( + navigateBack = { navController.popBackStack() }, + ) + } + } + + composable<SettingsRoutes.AccountSettingsScreenRoute> { + AccountSettingsScreen( + navigateBack = { navController.popBackStack() }, + ) + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsScreen.kt new file mode 100644 index 00000000..c5557151 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsScreen.kt @@ -0,0 +1,568 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package info.cemu.cemu.settings.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.R +import info.cemu.cemu.common.ui.components.Header +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.localization.getCurrentLocale +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeAccount +import info.cemu.cemu.nativeinterface.NativeAccount.AccountGender +import info.cemu.cemu.nativeinterface.NativeAccount.DEFAULT_MII_NAME +import info.cemu.cemu.nativeinterface.NativeAccount.MAX_ACCOUNT_COUNT +import info.cemu.cemu.nativeinterface.NativeAccount.MIN_ACCOUNT_COUNT +import info.cemu.cemu.nativeinterface.NativeSettings +import info.cemu.cemu.nativeinterface.NativeSettings.NetworkService +import info.cemu.cemu.common.string.parseHexOrNull +import java.text.SimpleDateFormat +import java.util.Date + +private val Countries = NativeAccount.getAccountCountries().toList() +private val CountriesIndices = Countries.map { it.index } +private val CountriesMap = Countries.associate { it.index to it.name } + +@Composable +fun AccountSettingsScreen( + navigateBack: () -> Unit, + accountsViewModel: AccountsViewModel = viewModel(), +) { + val accounts by accountsViewModel.accounts.collectAsState() + val activeAccountData by accountsViewModel.activeAccount.collectAsState() + val activeAccount by remember { + derivedStateOf { + accounts.firstOrNull { it.persistentId == activeAccountData.persistentId } + ?: accounts.first() + } + } + val onlineFullyValid = + activeAccount.isValid && accountsViewModel.onlineFilesStatus.hasRequiredOnlineFiles + val hasCustomNetworkConfiguration = remember { NativeSettings.hasCustomNetworkConfiguration() } + var showCreateAccountDialog by remember { mutableStateOf(false) } + var showDeleteAccountDialog by remember { mutableStateOf(false) } + + ScreenContent( + appBarText = tr("Account settings"), + navigateBack = navigateBack, + ) { + SingleSelection( + label = tr("Active account"), + choice = activeAccount, + choices = accounts, + choiceToString = { String.format("%s (%x)", it.miiName, it.persistentId) }, + onChoiceChanged = { accountsViewModel.setActiveAccount(it.persistentId) } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = accounts.size < MAX_ACCOUNT_COUNT, + onClick = { showCreateAccountDialog = true }) { + Text(tr("Create")) + } + + OutlinedButton( + enabled = accounts.size > MIN_ACCOUNT_COUNT, + onClick = { showDeleteAccountDialog = true } + ) { + Text(tr("Delete")) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + SingleSelection( + label = tr("Network service") + " (${activeAccount.miiName})", + enabled = onlineFullyValid, + choice = if (onlineFullyValid) activeAccountData.networkService else NetworkService.OFFLINE, + choiceToString = { networkServiceToString(it) }, + choices = listOf( + NetworkService.OFFLINE, + NetworkService.NINTENDO, + NetworkService.PRETENDO, + NetworkService.CUSTOM, + ), + isChoiceEnabled = { it != NetworkService.CUSTOM || hasCustomNetworkConfiguration }, + onChoiceChanged = accountsViewModel::setNetworkServiceForActiveAccount + ) + + OnlinePlayRequirements( + account = activeAccount, + onlineFilesStatus = accountsViewModel.onlineFilesStatus, + onlineFullyValid = onlineFullyValid, + onGetOnlineValidationErrors = accountsViewModel::getActiveAccountValidationErrors + ) + + AccountInformation( + account = activeAccount, + onDataChange = accountsViewModel::saveAccount, + ) + } + + if (showCreateAccountDialog) { + CreateAccountDialog( + onDismissRequest = { showCreateAccountDialog = false }, + onCreateAccount = { + accountsViewModel.createAccount(it) + showCreateAccountDialog = false + }, + onValidateCreateAccount = accountsViewModel::validateCreateAccount + ) + } + + if (showDeleteAccountDialog) { + DeleteActiveAccountConfirmationDialog( + onDismissRequest = { showDeleteAccountDialog = false }, + onConfirmation = { + accountsViewModel.deleteActiveAccount() + showDeleteAccountDialog = false + }, + account = activeAccount, + ) + } +} + +@Composable +private fun OnlinePlayRequirements( + account: NativeAccount.Account, + onlineFilesStatus: OnlineFilesStatus, + onlineFullyValid: Boolean, + onGetOnlineValidationErrors: () -> Array<NativeAccount.OnlineValidationError>, +) { + var onlineValidationErrors by remember { + mutableStateOf<Array<NativeAccount.OnlineValidationError>?>( + null + ) + } + + Header(tr("Online play requirements")) + + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (onlineFullyValid) Icons.Outlined.CheckCircle else Icons.Outlined.Warning, + modifier = Modifier.padding(end = 2.dp), + contentDescription = null + ) + Text(getAccountStatus(account, onlineFilesStatus)) + } + + if (!onlineFullyValid) { + Button( + onClick = { + if (onlineValidationErrors != null) return@Button + onlineValidationErrors = onGetOnlineValidationErrors() + }, + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp, start = 8.dp, end = 8.dp) + ) { + Text(tr("Show online status")) + } + } + + OnlineTutorial() + + onlineValidationErrors?.let { + OnlineErrorsDialog( + validationErrors = it, + onDismissRequest = { onlineValidationErrors = null }) + } +} + +@Composable +fun OnlineErrorsDialog( + validationErrors: Array<NativeAccount.OnlineValidationError>, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(tr("Online status")) + }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + validationErrors.forEach { + Text( + modifier = Modifier.padding(horizontal = 2.dp, vertical = 4.dp), + text = getErrorMessage(it) + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(tr("OK")) + } + }, + ) +} + +private fun getErrorMessage(error: NativeAccount.OnlineValidationError): String { + return when (error) { + is NativeAccount.AccountError -> getAccountErrorMessage(error.accountError) + is NativeAccount.CorruptedOTP -> tr("otp.bin is invalid") + is NativeAccount.CorruptedSEEPROM -> tr("seeprom.bin is invalid") + is NativeAccount.MissingFile -> tr("Missing certificate and key files:") + "\n${error.file}" + is NativeAccount.MissingOTP -> tr("otp.bin missing in Cemu directory") + is NativeAccount.MissingSEEPROM -> tr("seeprom.bin missing in Cemu directory") + } +} + +private fun getAccountErrorMessage(error: Int): String = when (error) { + NativeAccount.OnlineAccountError.NO_ACCOUNT_ID -> tr("AccountId missing (The account is not connected to a NNID/PNID)") + NativeAccount.OnlineAccountError.NO_PASSWORD_CACHED -> tr("IsPasswordCacheEnabled is set to false (The remember password option on your Wii U must be enabled for this account before dumping it)") + NativeAccount.OnlineAccountError.PASSWORD_CACHE_EMPTY -> tr("AccountPasswordCache is empty (The remember password option on your Wii U must be enabled for this account before dumping it)") + NativeAccount.OnlineAccountError.NO_PRINCIPAL_ID -> tr("PrincipalId missing") + else -> "no error" +} + +private fun getAccountStatus( + account: NativeAccount.Account, + onlineFilesStatus: OnlineFilesStatus, +): String { + if (onlineFilesStatus.hasRequiredOnlineFiles) { + return if (account.isValid) tr("Selected account is a valid online account") + else tr("Selected account is not linked to a NNID or PNID") + } + + return when { + onlineFilesStatus.isOTPPresent && onlineFilesStatus.isSEEPREOMPresent -> tr("OTP and SEEPROM present but no certificate files were found") + onlineFilesStatus.isOTPPresent != onlineFilesStatus.isSEEPREOMPresent -> tr("OTP.bin or SEEPROM.bin is missing") + else -> tr("Online play is not set up. Follow the guide below to get started") + } +} + +@Composable +private fun OnlineTutorial() { + Text( + modifier = Modifier.padding(8.dp), + text = buildAnnotatedString { + withLink( + LinkAnnotation.Url( + stringResource(R.string.cemu_online_guide), + TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.Underline, + ), + ) + ) + ) { + append(tr("Online play tutorial")) + } + } + ) +} + +@Composable +private fun AccountInformation( + account: NativeAccount.Account, + onDataChange: (NativeAccount.Account) -> Unit, +) { + Header(tr("Account information")) + + TextField( + value = account.persistentId.toUInt().toString(16), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + singleLine = true, + onValueChange = {}, + readOnly = true, + label = { Text(tr("PersistentId")) }, + ) + + TextField( + value = account.miiName, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + singleLine = true, + onValueChange = { onDataChange(account.copy(miiName = it.ifBlank { DEFAULT_MII_NAME })) }, + label = { Text(tr("Mii name")) }, + ) + + AccountBirthday( + account = account, + onDataChange = onDataChange, + ) + + SingleSelection( + choice = account.gender, + choiceToString = { accountGenderToString(it) }, + choices = listOf( + AccountGender.FEMALE, + AccountGender.MALE, + ), + onChoiceChanged = { onDataChange(account.copy(gender = it)) }, + label = tr("Gender"), + ) + + TextField( + value = account.email, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + singleLine = true, + onValueChange = { onDataChange(account.copy(email = it)) }, + label = { Text(tr("Email")) }, + ) + + SingleSelection( + label = tr("Country"), + choice = account.country, + choiceToString = { CountriesMap[it] ?: it.toString() }, + choices = CountriesIndices, + onChoiceChanged = { onDataChange(account.copy(country = it)) } + ) +} + +@Composable +private fun AccountBirthday( + account: NativeAccount.Account, + onDataChange: (NativeAccount.Account) -> Unit, +) { + var showDatePicker by remember { mutableStateOf(false) } + Text( + text = tr("Birthday: {0}", convertMillisToDate(account.birthday)), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp, start = 8.dp, end = 8.dp), + ) + + Button( + onClick = { showDatePicker = true }, + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp, start = 8.dp, end = 8.dp) + ) { + Text(tr("Pick a date")) + } + + if (showDatePicker) { + DatePickerModal( + initialDateMillis = account.birthday, + onDateSelected = { onDataChange(account.copy(birthday = it)) }, + onDismiss = { showDatePicker = false }, + ) + } +} + + +@Composable +private fun DatePickerModal( + initialDateMillis: Long, + onDateSelected: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialDateMillis) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onDateSelected(datePickerState.selectedDateMillis ?: 0) + onDismiss() + }) { + Text(tr("OK")) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(tr("Cancel")) + } + } + ) { + DatePicker(state = datePickerState) + } +} + + +private fun convertMillisToDate(millis: Long): String { + val formatter = SimpleDateFormat("yyyy-MM-dd", getCurrentLocale()) + return formatter.format(Date(millis)) +} + +@Composable +private fun DeleteActiveAccountConfirmationDialog( + account: NativeAccount.Account, + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(tr("Confirmation")) + }, + text = { + Text( + tr( + "Are you sure you want to delete the account {0} with id {1}?", + account.miiName, + account.persistentId.toUInt().toString(16) + ) + ) + }, + confirmButton = { + TextButton(onClick = onConfirmation) { + Text(tr("Yes")) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(tr("No")) + } + } + ) +} + +@Composable +private fun CreateAccountDialog( + onDismissRequest: () -> Unit, + onValidateCreateAccount: (CreateAccount) -> CreateAccountError?, + onCreateAccount: (CreateAccount) -> Unit, +) { + var createAccount by remember { + mutableStateOf( + CreateAccount( + persistentId = NativeAccount.MIN_PERSISTENT_ID.toInt(), + miiName = "", + ) + ) + } + val createError by remember { + derivedStateOf { + onValidateCreateAccount(createAccount) + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(tr("Create new account")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = createAccount.miiName, + singleLine = true, + isError = createError != null, + supportingText = { + if (createError is CreateAccountError.EmptyMiiName) { + Text(tr("Account name may not be empty!")) + } + }, + onValueChange = { createAccount = createAccount.copy(miiName = it) }, + label = { Text(tr("Mii name")) }, + ) + TextField( + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + value = createAccount.persistentId?.toUInt()?.toString(16) ?: "", + singleLine = true, + isError = createError != null, + supportingText = { + val currentError = createError + val errorMessage = when (currentError) { + is CreateAccountError.ConflictingPersistentId -> tr( + "The persistent id {0} is already in use by account {1}!", + currentError.existingPersistentId.toUInt().toString(16), + currentError.existingMiiName, + ) + + CreateAccountError.EmptyPersistentId -> tr("No persistent id entered!") + CreateAccountError.InvalidPersistentId -> tr( + "The persistent id must be greater than {0}!", + NativeAccount.MIN_PERSISTENT_ID.toString(16) + ) + + else -> return@TextField + } + + Text(errorMessage) + }, + onValueChange = { + if (it.isEmpty()) { + createAccount = createAccount.copy(persistentId = null) + return@TextField + } + val hexValue = it.parseHexOrNull() ?: return@TextField + createAccount = createAccount.copy(persistentId = hexValue.toInt()) + }, + label = { Text(tr("PersistentId")) }, + ) + } + }, + confirmButton = { + TextButton(onClick = { + if (createError == null) { + onCreateAccount(createAccount) + } + }) { + Text(tr("OK")) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(tr("Cancel")) + } + } + ) +} + +private fun accountGenderToString(gender: Byte) = when (gender) { + AccountGender.FEMALE -> tr("Female") + AccountGender.MALE -> tr("Male") + else -> throw IllegalArgumentException("Invalid account gender: $gender") +} + +private fun networkServiceToString(networkService: Int) = when (networkService) { + NetworkService.OFFLINE -> tr("Offline") + NetworkService.NINTENDO -> tr("Nintendo") + NetworkService.PRETENDO -> tr("Pretendo") + NetworkService.CUSTOM -> tr("Custom") + else -> throw IllegalArgumentException("Invalid network service: $networkService") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsViewModel.kt new file mode 100644 index 00000000..eafe5264 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/account/AccountsViewModel.kt @@ -0,0 +1,147 @@ +package info.cemu.cemu.settings.account + +import androidx.lifecycle.ViewModel +import info.cemu.cemu.nativeinterface.NativeAccount +import info.cemu.cemu.nativeinterface.NativeAccount.MAX_ACCOUNT_COUNT +import info.cemu.cemu.nativeinterface.NativeAccount.MIN_ACCOUNT_COUNT +import info.cemu.cemu.nativeinterface.NativeActiveSettings +import info.cemu.cemu.nativeinterface.NativeSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class ActiveAccount( + val persistentId: Int, + val networkService: Int, +) + +data class CreateAccount( + val persistentId: Int?, + val miiName: String, +) + +data class OnlineFilesStatus( + val hasRequiredOnlineFiles: Boolean, + val isOTPPresent: Boolean, + val isSEEPREOMPresent: Boolean, +) + +sealed class CreateAccountError { + object InvalidPersistentId : CreateAccountError() + object EmptyPersistentId : CreateAccountError() + data class ConflictingPersistentId( + val existingPersistentId: Int, + val existingMiiName: String, + ) : CreateAccountError() + + object EmptyMiiName : CreateAccountError() +} + +class AccountsViewModel : ViewModel() { + private val _accounts = MutableStateFlow(NativeAccount.getAccounts().toList()) + val accounts = _accounts.asStateFlow() + + val onlineFilesStatus = OnlineFilesStatus( + hasRequiredOnlineFiles = NativeActiveSettings.hasRequiredOnlineFiles(), + isOTPPresent = NativeAccount.isOTPPresent(), + isSEEPREOMPresent = NativeAccount.isSEEPROMPresent(), + ) + + private fun getActiveAccount(persistentId: Int): ActiveAccount { + val networkService = NativeSettings.getAccountNetworkService(persistentId) + + return ActiveAccount( + persistentId = persistentId, + networkService = networkService, + ) + } + + private val _activeAccount = + MutableStateFlow(getActiveAccount(NativeSettings.getAccountPersistentId())) + val activeAccount = _activeAccount.asStateFlow() + + fun setActiveAccount(persistentId: Int) { + if (!accounts.value.any { it.persistentId == persistentId }) { + return + } + + NativeSettings.setAccountPersistentId(persistentId) + _activeAccount.value = getActiveAccount(persistentId) + } + + fun setNetworkServiceForActiveAccount(networkService: Int) { + val activeAccount = activeAccount.value + NativeSettings.setAccountNetworkService(activeAccount.persistentId, networkService) + _activeAccount.value = activeAccount.copy(networkService = networkService) + } + + fun deleteActiveAccount() { + if (accounts.value.size <= MIN_ACCOUNT_COUNT) { + return + } + + val activeAccountPersistentId = activeAccount.value.persistentId + + NativeAccount.deleteAccount(activeAccountPersistentId) + refreshAccountList() + _activeAccount.value = getActiveAccount(_accounts.value.first().persistentId) + } + + + fun validateCreateAccount(createAccountData: CreateAccount): CreateAccountError? { + if (createAccountData.persistentId == null) { + return CreateAccountError.EmptyPersistentId + } + + if (createAccountData.persistentId.toUInt() < NativeAccount.MIN_PERSISTENT_ID) { + return CreateAccountError.InvalidPersistentId + } + + val existingAccount = + accounts.value.firstOrNull { it.persistentId == createAccountData.persistentId } + if (existingAccount != null) { + return CreateAccountError.ConflictingPersistentId( + existingAccount.persistentId, + existingAccount.miiName + ) + } + + if (createAccountData.miiName.isBlank()) { + return CreateAccountError.EmptyMiiName + } + + return null + } + + fun saveAccount(account: NativeAccount.Account) { + NativeAccount.saveAccount(account) + refreshAccountList() + } + + fun createAccount(createAccountData: CreateAccount) { + if (validateCreateAccount(createAccountData) != null) { + return + } + + if (accounts.value.size >= MAX_ACCOUNT_COUNT) { + return + } + + NativeAccount.createAccount(createAccountData.persistentId!!, createAccountData.miiName) + refreshAccountList() + } + + fun getActiveAccountValidationErrors(): Array<NativeAccount.OnlineValidationError> { + val persistentId = activeAccount.value.persistentId + val accounts = accounts.value + val activeAccount = + accounts.firstOrNull { it.persistentId == persistentId } + ?: accounts.firstOrNull() + ?: return arrayOf() + + return NativeAccount.getAccountValidationErrors(activeAccount.persistentId) + } + + fun refreshAccountList() { + _accounts.value = NativeAccount.getAccounts().toList() + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/audio/AudoSettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/audio/AudoSettingsScreen.kt new file mode 100644 index 00000000..79a58ef8 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/audio/AudoSettingsScreen.kt @@ -0,0 +1,84 @@ +package info.cemu.cemu.settings.audio + +import androidx.compose.runtime.Composable +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.components.Slider +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeSettings + +private const val AUDIO_LATENCY_STEPS = 22 + +@Composable +fun AudioSettingsScreen(navigateBack: () -> Unit) { + ScreenContent( + appBarText = tr("Audio settings"), + navigateBack = navigateBack, + ) { + Slider( + label = tr("Latency"), + initialValue = NativeSettings::getAudioLatency, + valueFrom = 0, + steps = AUDIO_LATENCY_STEPS, + valueTo = NativeSettings.AUDIO_LATENCY_MS_MAX, + onValueChange = NativeSettings::setAudioLatency, + labelFormatter = { "${it}ms" } + ) + Toggle( + label = tr("TV"), + description = tr("Enable audio output for the Wii U TV"), + initialCheckedState = { NativeSettings.getAudioDeviceEnabled(true) }, + onCheckedChanged = { NativeSettings.setAudioDeviceEnabled(it, true) } + ) + SingleSelection( + label = tr("TV channels"), + initialChoice = { NativeSettings.getAudioDeviceChannels(true) }, + onChoiceChanged = { NativeSettings.setAudioDeviceChannels(it, true) }, + choiceToString = { channelsToString(it) }, + choices = listOf( + NativeSettings.AudioChannels.MONO, + NativeSettings.AudioChannels.STEREO, + NativeSettings.AudioChannels.SURROUND, + ), + ) + Slider( + label = tr("TV volume"), + initialValue = { NativeSettings.getAudioDeviceVolume(true) }, + valueFrom = NativeSettings.AUDIO_MIN_VOLUME, + valueTo = NativeSettings.AUDIO_MAX_VOLUME, + onValueChange = { NativeSettings.setAudioDeviceVolume(it, true) }, + labelFormatter = { "$it%" } + ) + Toggle( + label = tr("Gamepad"), + description = tr("Enable audio output for the Wii U Gamepad"), + initialCheckedState = { NativeSettings.getAudioDeviceEnabled(false) }, + onCheckedChanged = { NativeSettings.setAudioDeviceEnabled(false, it) } + ) + SingleSelection( + label = tr("Gamepad channels"), + initialChoice = { NativeSettings.getAudioDeviceChannels(false) }, + onChoiceChanged = { NativeSettings.setAudioDeviceChannels(it, false) }, + choiceToString = { channelsToString(it) }, + choices = listOf( + NativeSettings.AudioChannels.STEREO, + ), + ) + Slider( + label = tr("Gamepad volume"), + initialValue = { NativeSettings.getAudioDeviceVolume(false) }, + valueFrom = NativeSettings.AUDIO_MIN_VOLUME, + valueTo = NativeSettings.AUDIO_MAX_VOLUME, + onValueChange = { NativeSettings.setAudioDeviceVolume(it, false) }, + labelFormatter = { "$it%" } + ) + } +} + +fun channelsToString(channels: Int) = when (channels) { + NativeSettings.AudioChannels.MONO -> tr("Mono") + NativeSettings.AudioChannels.STEREO -> tr("Stereo") + NativeSettings.AudioChannels.SURROUND -> tr("Surround") + else -> throw IllegalArgumentException("Invalid channels type: $channels") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversScreen.kt new file mode 100644 index 00000000..f01c4f1f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversScreen.kt @@ -0,0 +1,237 @@ +package info.cemu.cemu.settings.customdrivers + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.common.ui.components.ScreenContentLazy +import info.cemu.cemu.common.ui.localization.tr +import kotlinx.coroutines.launch + +@Composable +fun CustomDriversScreen( + navigateBack: () -> Unit, + customDriversViewModel: CustomDriversViewModel = viewModel(), +) { + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val installedDrivers by customDriversViewModel.installedDrivers.collectAsState() + val isSystemDriverSelected by customDriversViewModel.isSystemDriverSelected.collectAsState() + val isDriverInstallInProgress by customDriversViewModel.isDriverInstallInProgress.collectAsState() + val context = LocalContext.current + + val customDriversInstallLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + customDriversViewModel.installDriver(context, uri) { installStatus -> + val message = when (installStatus) { + DriverInstallStatus.AlreadyInstalled -> tr("Driver already installed") + DriverInstallStatus.ErrorInstalling -> tr("Failed to install driver") + DriverInstallStatus.Installed -> tr("Driver installed successfully") + } + + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message) + } + } + } + + ScreenContentLazy( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + appBarText = tr("Custom drivers"), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { customDriversInstallLauncher.launch(arrayOf("application/zip")) }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null + ) + } + }, + ) { + item { + SystemDriverListItem( + selected = isSystemDriverSelected, + onSelect = customDriversViewModel::setSystemDriverSelected + ) + } + items(installedDrivers) { + CustomDriverListItem( + driver = it, + onDelete = { customDriversViewModel.deleteDriver(it) }, + onSelect = { customDriversViewModel.setDriverSelected(it) } + ) + } + } + + if (isDriverInstallInProgress) + DriverInstallProgressDialog() +} + +@Composable +private fun DriverInstallProgressDialog() { + AlertDialog( + title = { + Text(tr("Installing")) + }, + text = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(tr("Installing driver in progress")) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + }, + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {} + ) +} + +@Composable +private fun SystemDriverListItem(selected: Boolean, onSelect: () -> Unit) { + DriverListItem( + driverLabel = tr("System driver"), + selected = selected, + onSelect = onSelect + ) +} + +@Composable +private fun CustomDriverListItem(driver: Driver, onDelete: () -> Unit, onSelect: () -> Unit) { + var showDriverInfo by remember { mutableStateOf(false) } + + DriverListItem( + driverLabel = driver.metadata.name, + selected = driver.selected, + onSelect = onSelect, + labelExtraContent = { + IconButton(onClick = { showDriverInfo = !showDriverInfo }) { + Icon( + modifier = Modifier.rotate(if (showDriverInfo) 180f else 0f), + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null + ) + } + } + ) { + if (showDriverInfo) { + DriverMetadataInfo(driver.metadata) + } + } +} + +@Composable +private fun DriverListItem( + driverLabel: String, + selected: Boolean, + onSelect: () -> Unit, + labelExtraContent: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit = {}, +) { + Card( + modifier = Modifier + .animateContentSize() + .fillMaxWidth() + .padding(8.dp), + onClick = onSelect + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = selected, onClick = onSelect) + Text( + modifier = Modifier + .padding(horizontal = 4.dp) + .basicMarquee(iterations = Int.MAX_VALUE) + .weight(1.0f), + text = driverLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + labelExtraContent() + } + content() + } +} + +@Composable +private fun DriverMetadataInfo(metadata: DriverMetadata) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + DriverMetadataInfo(tr("Description"), metadata.description) + DriverMetadataInfo(tr("Author"), metadata.author) + DriverMetadataInfo(tr("Package version"), metadata.packageVersion) + DriverMetadataInfo(tr("Vendor"), metadata.vendor) + DriverMetadataInfo(tr("Driver version"), metadata.driverVersion) + DriverMetadataInfo(tr("Min api"), metadata.minApi) + } +} + +@Composable +private fun <T> DriverMetadataInfo(label: String, info: T) { + Text( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 2.dp), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + text = label + ) + Text( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 2.dp), + fontSize = 14.sp, + text = info.toString(), + ) +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversViewModel.kt new file mode 100644 index 00000000..95ce88ff --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/customdrivers/CustomDriversViewModel.kt @@ -0,0 +1,230 @@ +@file:OptIn(ExperimentalPathApi::class, ExperimentalUuidApi::class) + +package info.cemu.cemu.settings.customdrivers + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import info.cemu.cemu.nativeinterface.NativeActiveSettings +import info.cemu.cemu.nativeinterface.NativeSettings +import info.cemu.cemu.common.io.decodeJsonFromFile +import info.cemu.cemu.common.io.unzip +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import java.io.File +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteRecursively +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.moveTo +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class DriverMetadata( + val schemaVersion: Int, + val name: String, + val description: String, + val author: String, + val packageVersion: String, + val vendor: String, + val driverVersion: String, + val minApi: Int, + val libraryName: String, +) + +data class Driver( + val path: String, + val metadata: DriverMetadata, + val selected: Boolean = false, +) + +enum class DriverInstallStatus { + Installed, + AlreadyInstalled, + ErrorInstalling, +} + +class CustomDriversViewModel : ViewModel() { + private val selectedDriverPath = MutableStateFlow(NativeSettings.getCustomDriverPath()) + val isSystemDriverSelected = selectedDriverPath.map { it == null }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + false + ) + + private val _installedDrivers = MutableStateFlow<List<Driver>>(emptyList()) + val installedDrivers = _installedDrivers.asStateFlow() + + init { + viewModelScope.launch { + _installedDrivers.value = parseInstalledDrivers() + } + } + + private suspend fun parseInstalledDrivers(): List<Driver> { + return withContext(Dispatchers.IO) { + val customDriversDir = getCustomDriversDir() + + if (!customDriversDir.isDirectory()) + return@withContext emptyList() + + val driverDirs: Array<File> = + customDriversDir.toFile().listFiles() ?: return@withContext emptyList() + + val drivers = mutableListOf<Driver>() + val selectedDriver = selectedDriverPath.value + + for (driverDir in driverDirs) { + if (!driverDir.isDirectory) + continue + val metadata = + decodeJsonFromFile<DriverMetadata>(driverDir.resolve(META_FILE_NAME)) + ?: continue + val driver = Driver( + path = driverDir.path, + metadata = metadata, + selected = selectedDriver == driverDir.path, + ) + drivers.add(driver) + } + + drivers.sortBy { it.metadata.name } + + return@withContext drivers + } + } + + private val _isDriverInstallInProgress = MutableStateFlow(false) + val isDriverInstallInProgress = _isDriverInstallInProgress.asStateFlow() + + fun installDriver( + context: Context, + driverZipUri: Uri, + onInstallFinished: (DriverInstallStatus) -> Unit, + ) { + _isDriverInstallInProgress.value = true + viewModelScope.launch(Dispatchers.IO) { + val tempDir = + Path(NativeActiveSettings.getUserDataPath()).resolve(Uuid.random().toString()) + + try { + tempDir.createDirectories() + + context.contentResolver.openInputStream(driverZipUri)?.use { + unzip(it, tempDir) + } + + val metadata = + decodeJsonFromFile<DriverMetadata>(tempDir.resolve(META_FILE_NAME).toFile()) + if (metadata == null + || metadata.minApi > Build.VERSION.SDK_INT + || metadata.schemaVersion != SUPPORTED_SCHEMA_VERSION + || !tempDir.resolve(metadata.libraryName).exists() + ) { + tempDir.deleteRecursively() + onInstallFinished(DriverInstallStatus.ErrorInstalling) + return@launch + } + + if (_installedDrivers.value.any { it.metadata == metadata }) { + tempDir.deleteRecursively() + onInstallFinished(DriverInstallStatus.AlreadyInstalled) + return@launch + } + + val customDriversDir = getCustomDriversDir() + customDriversDir.createDirectories() + val driverPath = tempDir.moveTo(customDriversDir.resolve(tempDir.fileName)) + + _installedDrivers.value = _installedDrivers.value.toMutableList().apply { + val driver = Driver( + metadata = metadata, + path = driverPath.toString(), + ) + add(driver) + sortBy { it.metadata.name } + } + + onInstallFinished(DriverInstallStatus.Installed) + } catch (exception: Exception) { + tempDir.deleteRecursively() + onInstallFinished(DriverInstallStatus.ErrorInstalling) + } finally { + _isDriverInstallInProgress.value = false + } + } + } + + fun deleteDriver(driver: Driver) { + if (!_installedDrivers.value.any { it == driver }) + return + + _installedDrivers.value -= driver + if (selectedDriverPath.value == driver.path) { + selectedDriverPath.value = null + NativeSettings.setCustomDriverPath(null) + } + + viewModelScope.launch(Dispatchers.IO) { + Path(driver.path).toFile().deleteRecursively() + } + } + + fun setSystemDriverSelected() { + if (selectedDriverPath.value == null) + return + + val installedDrivers = _installedDrivers.value.toMutableList() + val oldSelectedDriverIndex = installedDrivers.indexOfFirst { it.selected } + if (oldSelectedDriverIndex != -1) { + installedDrivers[oldSelectedDriverIndex] = + installedDrivers[oldSelectedDriverIndex].copy(selected = false) + _installedDrivers.value = installedDrivers + } + + selectedDriverPath.value = null + NativeSettings.setCustomDriverPath(null) + } + + fun setDriverSelected(driver: Driver) { + if (selectedDriverPath.value == driver.path) + return + + val installedDrivers = _installedDrivers.value.toMutableList() + + val oldSelectedDriverIndex = installedDrivers.indexOfFirst { it.selected } + if (oldSelectedDriverIndex != -1) + installedDrivers[oldSelectedDriverIndex] = + installedDrivers[oldSelectedDriverIndex].copy(selected = false) + + val newSelectedDriverIndex = installedDrivers.indexOf(driver) + if (newSelectedDriverIndex == -1) + return + installedDrivers[newSelectedDriverIndex] = driver.copy(selected = true) + + _installedDrivers.value = installedDrivers + + NativeSettings.setCustomDriverPath(driver.path) + selectedDriverPath.value = driver.path + } + + companion object { + private const val SUPPORTED_SCHEMA_VERSION = 1 + private const val META_FILE_NAME = "meta.json" + private const val CUSTOM_DRIVERS_DIR_NAME = "customDrivers" + private fun getCustomDriversDir() = + Path(NativeActiveSettings.getUserDataPath()).resolve(CUSTOM_DRIVERS_DIR_NAME) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamePathsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamePathsScreen.kt new file mode 100644 index 00000000..1b1140a2 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamePathsScreen.kt @@ -0,0 +1,117 @@ +package info.cemu.cemu.settings.gamespath + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.basicMarquee +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.common.ui.components.ScreenContentLazy +import info.cemu.cemu.common.ui.localization.tr +import kotlinx.coroutines.launch + +@Composable +fun GamePathsScreen( + navigateBack: () -> Unit, + gamesPathsViewModel: GamesPathsViewModel = viewModel(), +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val gamesPaths by gamesPathsViewModel.gamesPaths.collectAsState() + val launcher = + 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 + val gamesPath = documentFile.uri.toString() + if (gamesPaths.contains(gamesPath)) { + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(tr("Games path already added")) + } + return@rememberLauncherForActivityResult + } + gamesPathsViewModel.addGamesPath(gamesPath) + } + ScreenContentLazy( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + appBarText = tr("Game paths"), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { launcher.launch(null) }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null + ) + } + }, + ) { + items(items = gamesPaths, key = { it }) { + GamePathsListItem( + modifier = Modifier.animateItem(), + gamesPath = it, + onDelete = { gamesPathsViewModel.removeGamesPath(it) } + ) + } + } +} + +@Composable +fun GamePathsListItem( + modifier: Modifier, + gamesPath: String, + onDelete: () -> Unit, +) { + Card(modifier = modifier.padding(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + modifier = Modifier + .weight(1.0f) + .padding(horizontal = 12.dp, vertical = 8.dp) + .basicMarquee(), + text = gamesPath, + maxLines = 1, + ) + IconButton( + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp, end = 8.dp), + onClick = onDelete + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null + ) + } + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamesPathsViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamesPathsViewModel.kt new file mode 100644 index 00000000..f84ee998 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/gamespath/GamesPathsViewModel.kt @@ -0,0 +1,26 @@ +package info.cemu.cemu.settings.gamespath + +import androidx.lifecycle.ViewModel +import info.cemu.cemu.nativeinterface.NativeSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + + +class GamesPathsViewModel : ViewModel() { + private val _gamesPaths = MutableStateFlow(NativeSettings.getGamesPaths().toList()) + val gamesPaths = _gamesPaths.asStateFlow() + + fun addGamesPath(gamesPath: String) { + if (!_gamesPaths.value.contains(gamesPath)) { + _gamesPaths.value += gamesPath + NativeSettings.addGamesPath(gamesPath) + } + } + + fun removeGamesPath(gamesPath: String) { + if (_gamesPaths.value.contains(gamesPath)) { + _gamesPaths.value -= gamesPath + NativeSettings.removeGamesPath(gamesPath) + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsScreen.kt new file mode 100644 index 00000000..2cb99cd5 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsScreen.kt @@ -0,0 +1,90 @@ +package info.cemu.cemu.settings.general + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.common.settings.GamePadPosition +import info.cemu.cemu.common.ui.components.Button +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeSettings + +@Composable +fun GeneralSettingsScreen( + navigateBack: () -> Unit, + goToGamePathsSettings: () -> Unit, + generalSettingsViewModel: GeneralSettingsViewModel = viewModel(), +) { + val context = LocalContext.current + + ScreenContent( + appBarText = tr("General settings"), + navigateBack = navigateBack, + ) { + Button( + label = tr("Add game path"), + description = tr("Add the root directory of your game(s). It will scan all directories in it for games"), + onClick = dropUnlessResumed { goToGamePathsSettings() }, + ) + SingleSelection( + label = tr("Language"), + initialChoice = { generalSettingsViewModel.guiSettings.language }, + onChoiceChanged = { generalSettingsViewModel.setLanguage(language = it, context) }, + choiceToString = { generalSettingsViewModel.languageToDisplayNameMap[it] ?: it }, + choices = generalSettingsViewModel.languages, + ) + SingleSelection( + label = tr("Console language"), + initialChoice = NativeSettings::getConsoleLanguage, + onChoiceChanged = NativeSettings::setConsoleLanguage, + choiceToString = { consoleLanguageToString(it) }, + choices = listOf( + NativeSettings.ConsoleLanguage.JAPANESE, + NativeSettings.ConsoleLanguage.ENGLISH, + NativeSettings.ConsoleLanguage.FRENCH, + NativeSettings.ConsoleLanguage.GERMAN, + NativeSettings.ConsoleLanguage.ITALIAN, + NativeSettings.ConsoleLanguage.SPANISH, + NativeSettings.ConsoleLanguage.CHINESE, + NativeSettings.ConsoleLanguage.KOREAN, + NativeSettings.ConsoleLanguage.DUTCH, + NativeSettings.ConsoleLanguage.PORTUGUESE, + NativeSettings.ConsoleLanguage.RUSSIAN, + NativeSettings.ConsoleLanguage.TAIWANESE, + ), + ) + + SingleSelection( + label = tr("GamePad position"), + initialChoice = { generalSettingsViewModel.emulationSettings.gamePadPosition }, + onChoiceChanged = { generalSettingsViewModel.emulationSettings.gamePadPosition = it }, + choiceToString = { gamePadPositionToString(it) }, + choices = GamePadPosition.entries, + ) + } +} + +private fun gamePadPositionToString(position: GamePadPosition) = when (position) { + GamePadPosition.ABOVE -> tr("Above") + GamePadPosition.BELOW -> tr("Below") + GamePadPosition.LEFT -> tr("Left") + GamePadPosition.RIGHT -> tr("Right") +} + +private fun consoleLanguageToString(channels: Int): String = when (channels) { + NativeSettings.ConsoleLanguage.JAPANESE -> tr("Japanese") + NativeSettings.ConsoleLanguage.ENGLISH -> tr("English") + NativeSettings.ConsoleLanguage.FRENCH -> tr("French") + NativeSettings.ConsoleLanguage.GERMAN -> tr("German") + NativeSettings.ConsoleLanguage.ITALIAN -> tr("Italian") + NativeSettings.ConsoleLanguage.SPANISH -> tr("Spanish") + NativeSettings.ConsoleLanguage.CHINESE -> tr("Chinese") + NativeSettings.ConsoleLanguage.KOREAN -> tr("Korean") + NativeSettings.ConsoleLanguage.DUTCH -> tr("Dutch") + NativeSettings.ConsoleLanguage.PORTUGUESE -> tr("Portuguese") + NativeSettings.ConsoleLanguage.RUSSIAN -> tr("Russian") + NativeSettings.ConsoleLanguage.TAIWANESE -> tr("Taiwanese") + else -> throw IllegalArgumentException("Invalid console language: $channels") +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsViewModel.kt new file mode 100644 index 00000000..aa880b6b --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/general/GeneralSettingsViewModel.kt @@ -0,0 +1,26 @@ +package info.cemu.cemu.settings.general + +import android.content.Context +import androidx.lifecycle.ViewModel +import info.cemu.cemu.common.settings.EmulationSettings +import info.cemu.cemu.common.settings.GuiSettings +import info.cemu.cemu.common.settings.SettingsManager +import info.cemu.cemu.common.ui.localization.getAvailableLanguages + +class GeneralSettingsViewModel : ViewModel() { + val languages: List<String> + val languageToDisplayNameMap: Map<String, String> + val emulationSettings: EmulationSettings = SettingsManager.emulationSettings + val guiSettings: GuiSettings = SettingsManager.guiSettings + + init { + val availableLanguages = getAvailableLanguages() + languages = availableLanguages.map { it.code } + languageToDisplayNameMap = availableLanguages.associateBy({ it.code }, { it.displayName }) + } + + fun setLanguage(language: String, context: Context) { + info.cemu.cemu.common.ui.localization.setLanguage(language, context) + guiSettings.language = language + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/graphics/GraphicsSettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/graphics/GraphicsSettingsScreen.kt new file mode 100644 index 00000000..c8ea9b5e --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/graphics/GraphicsSettingsScreen.kt @@ -0,0 +1,104 @@ +package info.cemu.cemu.settings.graphics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveable +import info.cemu.cemu.common.ui.components.Button +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeEmulation +import info.cemu.cemu.nativeinterface.NativeSettings + +private val ScalingFilterChoices = listOf( + NativeSettings.ScalingFilter.BILINEAR_FILTER, + NativeSettings.ScalingFilter.BICUBIC_FILTER, + NativeSettings.ScalingFilter.BICUBIC_HERMITE_FILTER, + NativeSettings.ScalingFilter.NEAREST_NEIGHBOR_FILTER +) + +@Composable +fun GraphicsSettingsScreen(navigateBack: () -> Unit, goToCustomDriversSettings: () -> Unit) { + val supportsLoadingCustomDrivers = + rememberSaveable { NativeEmulation.supportsLoadingCustomDriver() } + + ScreenContent( + appBarText = tr("Graphics settings"), + navigateBack = navigateBack, + ) { + if (supportsLoadingCustomDrivers) { + Button( + label = tr("Custom drivers"), + onClick = goToCustomDriversSettings + ) + } + Toggle( + label = tr("Async shader compile"), + description = tr("Enables async shader and pipeline compilation. Reduces stutter at the cost of objects not rendering for a short time.\nVulkan only"), + initialCheckedState = NativeSettings::getAsyncShaderCompile, + onCheckedChanged = NativeSettings::setAsyncShaderCompile, + ) + SingleSelection( + label = tr("VSync"), + initialChoice = NativeSettings::getVsyncMode, + onChoiceChanged = NativeSettings::setVsyncMode, + choiceToString = { vsyncModeToString(it) }, + choices = listOf( + NativeSettings.VSyncMode.OFF, + NativeSettings.VSyncMode.DOUBLE_BUFFERING, + NativeSettings.VSyncMode.TRIPLE_BUFFERING + ), + ) + Toggle( + label = tr("Accurate barriers"), + description = tr("Disabling the accurate barriers option will lead to flickering graphics but may improve performance. It is highly recommended to leave it turned on"), + initialCheckedState = NativeSettings::getAccurateBarriers, + onCheckedChanged = NativeSettings::setAccurateBarriers, + ) + SingleSelection( + label = tr("Fullscreen scaling"), + initialChoice = NativeSettings::getFullscreenScaling, + onChoiceChanged = NativeSettings::setFullscreenScaling, + choiceToString = { fullscreenScalingModeToString(it) }, + choices = listOf( + NativeSettings.FullscreenScaling.KEEP_ASPECT_RATIO, + NativeSettings.FullscreenScaling.STRETCH + ), + ) + SingleSelection( + label = tr("Upscale filter"), + initialChoice = NativeSettings::getUpscalingFilter, + onChoiceChanged = NativeSettings::setUpscalingFilter, + choiceToString = { scalingFilterToString(it) }, + choices = ScalingFilterChoices, + ) + SingleSelection( + label = tr("Downscale filter"), + initialChoice = NativeSettings::getDownscalingFilter, + onChoiceChanged = NativeSettings::setDownscalingFilter, + choiceToString = { scalingFilterToString(it) }, + choices = ScalingFilterChoices, + ) + } +} + +private fun scalingFilterToString(scalingFilter: Int) = when (scalingFilter) { + NativeSettings.ScalingFilter.BILINEAR_FILTER -> tr("Bilinear") + NativeSettings.ScalingFilter.BICUBIC_FILTER -> tr("Bicubic") + NativeSettings.ScalingFilter.BICUBIC_HERMITE_FILTER -> tr("Hermite") + NativeSettings.ScalingFilter.NEAREST_NEIGHBOR_FILTER -> tr("Nearest neighbor") + else -> throw IllegalArgumentException("Invalid scaling filter: $scalingFilter") +} + +private fun vsyncModeToString(vsyncMode: Int) = when (vsyncMode) { + NativeSettings.VSyncMode.OFF -> tr("Off") + NativeSettings.VSyncMode.DOUBLE_BUFFERING -> tr("Double buffering") + NativeSettings.VSyncMode.TRIPLE_BUFFERING -> tr("Triple buffering") + else -> throw IllegalArgumentException("Invalid vsync mode: $vsyncMode") +} + +private fun fullscreenScalingModeToString(fullscreenScaling: Int) = when (fullscreenScaling) { + NativeSettings.FullscreenScaling.KEEP_ASPECT_RATIO -> tr("Keep aspect ratio") + NativeSettings.FullscreenScaling.STRETCH -> tr("Stretch") + else -> throw IllegalArgumentException("Invalid fullscreen scaling mode: $fullscreenScaling") +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/ClassicControllerInputs.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ClassicControllerInputs.kt new file mode 100644 index 00000000..da076e79 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ClassicControllerInputs.kt @@ -0,0 +1,95 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.runtime.Composable +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput.ClassicButton + +@Composable +fun ClassicControllerInputs( + onInputClick: (String, Int) -> Unit, + controlsMapping: Map<Int, String>, +) { + @Composable + fun InputItemsGroup( + groupName: String, + inputIds: List<Int>, + ) { + InputItemsGroup( + groupName = groupName, + inputIds = inputIds, + inputIdToString = ::classicControllerButtonToString, + onInputClick = onInputClick, + controlsMapping = controlsMapping, + ) + } + InputItemsGroup( + groupName = tr("Buttons"), + inputIds = listOf( + ClassicButton.A, + ClassicButton.B, + ClassicButton.X, + ClassicButton.Y, + ClassicButton.L, + ClassicButton.R, + ClassicButton.ZL, + ClassicButton.ZR, + ClassicButton.PLUS, + ClassicButton.MINUS, + ClassicButton.HOME + ) + ) + InputItemsGroup( + groupName = tr("D-pad"), + inputIds = listOf( + ClassicButton.UP, + ClassicButton.DOWN, + ClassicButton.LEFT, + ClassicButton.RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Left Axis"), + inputIds = listOf( + ClassicButton.STICKL_UP, + ClassicButton.STICKL_DOWN, + ClassicButton.STICKL_LEFT, + ClassicButton.STICKL_RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Right Axis"), + inputIds = listOf( + ClassicButton.STICKR_UP, + ClassicButton.STICKR_DOWN, + ClassicButton.STICKR_LEFT, + ClassicButton.STICKR_RIGHT + ) + ) +} + +private fun classicControllerButtonToString(buttonId: Int) = when (buttonId) { + ClassicButton.A -> "A" + ClassicButton.B -> "B" + ClassicButton.X -> "X" + ClassicButton.Y -> "Y" + ClassicButton.L -> "L" + ClassicButton.R -> "R" + ClassicButton.ZL -> "ZL" + ClassicButton.ZR -> "ZR" + ClassicButton.PLUS -> "+" + ClassicButton.MINUS -> "-" + ClassicButton.HOME -> tr("home") + ClassicButton.UP -> tr("up") + ClassicButton.DOWN -> tr("down") + ClassicButton.LEFT -> tr("left") + ClassicButton.RIGHT -> tr("right") + ClassicButton.STICKL_UP -> tr("up") + ClassicButton.STICKL_DOWN -> tr("down") + ClassicButton.STICKL_LEFT -> tr("left") + ClassicButton.STICKL_RIGHT -> tr("right") + ClassicButton.STICKR_UP -> tr("up") + ClassicButton.STICKR_DOWN -> tr("down") + ClassicButton.STICKR_LEFT -> tr("left") + ClassicButton.STICKR_RIGHT -> tr("right") + else -> throw IllegalArgumentException("Invalid buttonId $buttonId for Classic controller type") +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllerInputSettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllerInputSettingsScreen.kt new file mode 100644 index 00000000..8a145db8 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllerInputSettingsScreen.kt @@ -0,0 +1,231 @@ +package info.cemu.cemu.settings.input + +import android.content.Context +import android.view.KeyEvent +import android.view.MotionEvent +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.localization.controllerTypeToString +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput +import kotlinx.coroutines.launch +import androidx.compose.material3.Button as MaterialButton + +@Composable +fun ControllerInputSettingsScreen( + navigateBack: () -> Unit, + controllerIndex: Int, + controllersViewModel: ControllersViewModel = viewModel( + factory = ControllersViewModel.Factory, + extras = MutableCreationExtras().apply { + set(ControllersViewModel.CONTROLLER_INDEX_KEY, controllerIndex) + } + ), +) { + val context = LocalContext.current + val controllerType by controllersViewModel.controllerType.collectAsState() + val controls by controllersViewModel.controls.collectAsState() + val controllers by controllersViewModel.controllers.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + fun onInputClick(buttonName: String, buttonId: Int) { + openInputDialog( + context = context, + buttonName = buttonName, + onClear = { controllersViewModel.clearButtonMapping(buttonId) }, + mapKeyEvent = { controllersViewModel.mapKeyEvent(it, buttonId) }, + tryMapMotionEvent = { controllersViewModel.tryMapMotionEvent(it, buttonId) }, + ) + } + + ScreenContent( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + appBarText = tr("Controller {0}", controllerIndex + 1), + navigateBack = navigateBack, + ) { + + SingleSelection( + isChoiceEnabled = controllersViewModel::isControllerTypeAllowed, + label = tr("Emulated controller"), + initialChoice = { controllerType }, + choices = listOf( + NativeInput.EmulatedControllerType.DISABLED, + NativeInput.EmulatedControllerType.VPAD, + NativeInput.EmulatedControllerType.PRO, + NativeInput.EmulatedControllerType.CLASSIC, + NativeInput.EmulatedControllerType.WIIMOTE + ), + choiceToString = { controllerTypeToString(it) }, + onChoiceChanged = controllersViewModel::setControllerType + ) + + if (controllerType != NativeInput.EmulatedControllerType.DISABLED) { + MaterialButton( + modifier = Modifier.padding(8.dp), + onClick = { + controllersViewModel.refreshAvailableControllers { + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(tr("No controllers available")) + } + } + } + ) { + Text(tr("Setup all inputs")) + } + } + + controllers?.let { + ControllerSelectDialog( + controllers = it, + onDismissRequest = controllersViewModel::clearGameControllers, + onSelect = { deviceId -> + controllersViewModel.mapAllInputs(deviceId) + controllersViewModel.clearGameControllers() + } + ) + } + + when (controllerType) { + NativeInput.EmulatedControllerType.VPAD -> VPADInputs( + controllerIndex = controllerIndex, + onInputClick = ::onInputClick, + controlsMapping = controls, + ) + + NativeInput.EmulatedControllerType.PRO -> ProControllerInputs( + onInputClick = ::onInputClick, + controlsMapping = controls, + ) + + NativeInput.EmulatedControllerType.CLASSIC -> ClassicControllerInputs( + onInputClick = ::onInputClick, + controlsMapping = controls, + ) + + NativeInput.EmulatedControllerType.WIIMOTE -> WiimoteControllerInputs( + onInputClick = ::onInputClick, + controlsMapping = controls, + ) + } + } +} + +@Composable +private fun ControllerSelectDialog( + controllers: List<Pair<String, Int>>, + onDismissRequest: () -> Unit, + onSelect: (Int) -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = Modifier + .sizeIn(maxWidth = 560.dp, maxHeight = 560.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 8.dp + ), + text = tr("Select a controller"), + fontSize = 24.sp, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp) + .weight(weight = 1.0f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + controllers.forEach { (controllerName, deviceId) -> + Text( + text = controllerName, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(deviceId) } + .padding(vertical = 16.dp, horizontal = 8.dp) + ) + } + } + + HorizontalDivider() + + TextButton( + onClick = onDismissRequest, + modifier = Modifier + .padding(8.dp) + .align(Alignment.End), + ) { + Text(tr("Cancel")) + } + } + } +} + +private fun openInputDialog( + context: Context, + buttonName: String, + onClear: () -> Unit, + mapKeyEvent: (KeyEvent) -> Unit, + tryMapMotionEvent: (MotionEvent) -> Boolean, +) { + MaterialAlertDialogBuilder(context).setTitle(tr("Input binding")) + .setMessage(tr("Trigger an input to bind it to {0}", buttonName)) + .setNeutralButton(tr("Clear")) { _, _ -> onClear() } + .setNegativeButton(tr("Cancel")) { _, _ -> } + .show() + .also { alertDialog -> + alertDialog.requireViewById<TextView>(android.R.id.message).apply { + isFocusableInTouchMode = true + requestFocus() + setOnKeyListener { _, _, keyEvent: KeyEvent -> + mapKeyEvent(keyEvent) + alertDialog.dismiss() + true + } + setOnGenericMotionListener { _, motionEvent: MotionEvent? -> + if (motionEvent != null && tryMapMotionEvent(motionEvent)) { + alertDialog.dismiss() + } + true + } + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllersViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllersViewModel.kt new file mode 100644 index 00000000..31937bcc --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ControllersViewModel.kt @@ -0,0 +1,117 @@ +package info.cemu.cemu.settings.input + +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import info.cemu.cemu.nativeinterface.NativeInput +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ControllersViewModel(val controllerIndex: Int) : ViewModel() { + private var _controllerType = MutableStateFlow( + if (NativeInput.isControllerDisabled(controllerIndex)) NativeInput.EmulatedControllerType.DISABLED + else NativeInput.getControllerType(controllerIndex) + ) + val controllerType = _controllerType.asStateFlow() + + private val _controls = MutableStateFlow<Map<Int, String>>(emptyMap()) + val controls = _controls.asStateFlow() + + private val _controllers = MutableStateFlow<List<Pair<String, Int>>?>(null) + val controllers = _controllers.asStateFlow() + + private var vpadCount = 0 + private var wpadCount = 0 + + private fun getControllerMapping(buttonId: Int) = + buttonId to NativeInput.getControllerMapping(controllerIndex, buttonId) + + fun setControllerType(controllerType: Int) { + if (!isControllerTypeAllowed(controllerType) || _controllerType.value == controllerType) + return + _controllerType.value = controllerType + NativeInput.setControllerType(controllerIndex, controllerType) + refreshControllerData() + } + + fun mapKeyEvent(keyEvent: KeyEvent, buttonId: Int) { + InputMapper.mapKeyEventToMappingId(controllerIndex, buttonId, keyEvent) + _controls.value += getControllerMapping(buttonId) + } + + fun refreshAvailableControllers(onNoControllersAvailable: () -> Unit) { + val newControllers = InputMapper.getGameControllers() + if (newControllers.isEmpty()) { + _controllers.value = null + onNoControllersAvailable() + return + } + _controllers.value = newControllers + } + + fun clearGameControllers() { + _controllers.value = null + } + + fun mapAllInputs(deviceId: Int) { + val oldControls = _controls.value + _controls.value = emptyMap() + oldControls.keys.forEach { NativeInput.clearControllerMapping(controllerIndex, it) } + + InputMapper.mapAllInputs(deviceId, controllerIndex) + + val buttons = getNativeButtonsForControllerType(controllerType.value) + buttons.forEach { _controls.value += getControllerMapping(it.nativeKeyCode) } + } + + fun tryMapMotionEvent(motionEvent: MotionEvent, buttonId: Int): Boolean { + if (InputMapper.tryMapMotionEventToMappingId(controllerIndex, buttonId, motionEvent)) { + _controls.value += getControllerMapping(buttonId) + return true + } + return false + } + + fun clearButtonMapping(buttonId: Int) { + _controls.value -= buttonId + NativeInput.clearControllerMapping(controllerIndex, buttonId) + } + + private fun refreshControllerData() { + vpadCount = NativeInput.VPADControllersCount + wpadCount = NativeInput.WPADControllersCount + _controls.value = NativeInput.getControllerMappings(controllerIndex) + } + + fun isControllerTypeAllowed(controllerType: Int): Boolean { + val currentControllerType = this.controllerType.value + if (controllerType == NativeInput.EmulatedControllerType.DISABLED) { + return true + } + if (controllerType == NativeInput.EmulatedControllerType.VPAD) { + return currentControllerType == NativeInput.EmulatedControllerType.VPAD || vpadCount < NativeInput.MAX_VPAD_CONTROLLERS + } + val isWPAD = currentControllerType != NativeInput.EmulatedControllerType.VPAD + && currentControllerType != NativeInput.EmulatedControllerType.DISABLED + return isWPAD || wpadCount < NativeInput.MAX_WPAD_CONTROLLERS + } + + init { + refreshControllerData() + } + + companion object { + val CONTROLLER_INDEX_KEY = object : CreationExtras.Key<Int> {} + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + ControllersViewModel( + this[CONTROLLER_INDEX_KEY] as Int + ) + } + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputItemGroup.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputItemGroup.kt new file mode 100644 index 00000000..4ba2a5db --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputItemGroup.kt @@ -0,0 +1,56 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import info.cemu.cemu.common.ui.components.Header + +@Composable +fun InputItemsGroup( + groupName: String, + inputIds: List<Int>, + inputIdToString: (Int) -> String, + onInputClick: (String, Int) -> Unit, + controlsMapping: Map<Int, String>, +) { + Header(groupName) + inputIds.forEach { + val buttonName = inputIdToString(it) + InputItem( + buttonName = buttonName, + mapping = controlsMapping[it], + onClick = { onInputClick(buttonName, it) } + ) + } +} + +@Composable +fun InputItem( + buttonName: String, + mapping: String?, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = buttonName, + fontSize = 18.sp, + ) + Text( + text = mapping ?: "", + fontSize = 16.sp + ) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputMapper.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputMapper.kt new file mode 100644 index 00000000..b7cfec0c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputMapper.kt @@ -0,0 +1,534 @@ +package info.cemu.cemu.settings.input + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import info.cemu.cemu.common.android.inputdevice.isGameController +import info.cemu.cemu.common.android.motionevent.isMotionEventFromJoystickOrGamepad +import info.cemu.cemu.nativeinterface.NativeInput +import info.cemu.cemu.nativeinterface.NativeInput.setControllerMapping +import kotlin.math.abs + +private fun getNativeAxisKey(axis: Int, isPositive: Boolean): Int? { + return if (isPositive) { + when (axis) { + MotionEvent.AXIS_X -> NativeInput.Axis.X_POS + MotionEvent.AXIS_Y -> NativeInput.Axis.Y_POS + MotionEvent.AXIS_RX, MotionEvent.AXIS_Z -> NativeInput.Axis.ROTATION_X_POS + MotionEvent.AXIS_RY, MotionEvent.AXIS_RZ -> NativeInput.Axis.ROTATION_Y_POS + MotionEvent.AXIS_LTRIGGER -> NativeInput.Axis.TRIGGER_X_POS + MotionEvent.AXIS_RTRIGGER -> NativeInput.Axis.TRIGGER_Y_POS + MotionEvent.AXIS_HAT_X -> NativeInput.Axis.DPAD_RIGHT + MotionEvent.AXIS_HAT_Y -> NativeInput.Axis.DPAD_DOWN + else -> null + } + } else { + when (axis) { + MotionEvent.AXIS_X -> NativeInput.Axis.X_NEG + MotionEvent.AXIS_Y -> NativeInput.Axis.Y_NEG + MotionEvent.AXIS_RX, MotionEvent.AXIS_Z -> NativeInput.Axis.ROTATION_X_NEG + MotionEvent.AXIS_RY, MotionEvent.AXIS_RZ -> NativeInput.Axis.ROTATION_Y_NEG + MotionEvent.AXIS_HAT_X -> NativeInput.Axis.DPAD_LEFT + MotionEvent.AXIS_HAT_Y -> NativeInput.Axis.DPAD_UP + else -> null + } + } +} + +private const val MIN_ABS_AXIS_VALUE = 0.33f + +object InputMapper { + fun tryMapMotionEventToMappingId( + controllerIndex: Int, + mappingId: Int, + event: MotionEvent, + ): Boolean { + if (!event.isMotionEventFromJoystickOrGamepad()) { + return false + } + val device = event.device + var maxAbsAxisValue = 0.0f + var maxAxis = -1 + val actionPointerIndex = event.actionIndex + for (motionRange in device.motionRanges) { + val axisValue = event.getAxisValue(motionRange.axis, actionPointerIndex) + val axis = getNativeAxisKey(motionRange.axis, axisValue > 0) ?: continue + if (abs(axisValue.toDouble()) > maxAbsAxisValue) { + maxAxis = axis + maxAbsAxisValue = abs(axisValue.toDouble()).toFloat() + } + } + if (maxAbsAxisValue > MIN_ABS_AXIS_VALUE) { + setControllerMapping( + device.descriptor, + device.name, + controllerIndex, + mappingId, + maxAxis + ) + return true + } + return false + } + + + fun mapKeyEventToMappingId(controllerIndex: Int, mappingId: Int, event: KeyEvent) { + val device = event.device + setControllerMapping( + device.descriptor, + device.name, + controllerIndex, + mappingId, + event.keyCode + ) + } + + private fun mapAxisCodeToMappingId( + controllerIndex: Int, + mappingId: Int, + deviceDescriptor: String, + deviceName: String, + axisCode: Int, + isPositive: Boolean, + ) { + val axis = getNativeAxisKey(axisCode, isPositive) ?: return + setControllerMapping( + deviceDescriptor, + deviceName, + controllerIndex, + mappingId, + axis + ) + } + + private fun mapKeyCodeToMappingId( + controllerIndex: Int, + mappingId: Int, + deviceDescriptor: String, + deviceName: String, + keyCode: Int, + ) { + setControllerMapping( + deviceDescriptor, + deviceName, + controllerIndex, + mappingId, + keyCode + ) + } + + fun getGameControllers(): List<Pair<String, Int>> { + val gameControllers = mutableListOf<Pair<String, Int>>() + + InputDevice.getDeviceIds().forEach { deviceId -> + val device = InputDevice.getDevice(deviceId) ?: return@forEach + + if (gameControllers.any { (_, id) -> id == deviceId }) { + return@forEach + } + + if (device.isGameController()) { + gameControllers.add(Pair(device.name, deviceId)) + } + } + + return gameControllers + } + + fun mapAllInputs(deviceId: Int, controllerIndex: Int) { + if (NativeInput.isControllerDisabled(controllerIndex)) { + return + } + val controllerType = NativeInput.getControllerType(controllerIndex) + val device = InputDevice.getDevice(deviceId) ?: return + + val inputs = mutableMapOf<InputMapping, Boolean>().apply { + val buttonKeyCodes = + ButtonInputMapping.entries.map { it.keyCode }.toIntArray() + ButtonInputMapping.entries + .zip(device.hasKeys(*buttonKeyCodes).toTypedArray()) + .forEach { (button, hasKey) -> put(button, hasKey) } + + device.motionRanges.forEach { motionRange -> + AxisInputMapping.entries.filter { + val isPositive = motionRange.min < 0 && !it.isPositive + val isNegative = motionRange.max > 0 && it.isPositive + (isPositive || isNegative) && it.axisCode == motionRange.axis + }.forEach { put(it, true) } + } + } + + val buttons = getNativeButtonsForControllerType(controllerType) + + for (button in buttons) { + val mapping = getButtonMappings(button).firstOrNull { inputs[it] == true } + ?: FALLBACK_BUTTONS.firstOrNull { inputs[it] == true } + if (mapping == null) { + continue + } + inputs[mapping] = false + + val buttonId = button.nativeKeyCode + + if (mapping is ButtonInputMapping) { + mapKeyCodeToMappingId( + controllerIndex, + buttonId, + device.descriptor, + device.name, + mapping.keyCode + ) + } + + if (mapping is AxisInputMapping) { + mapAxisCodeToMappingId( + controllerIndex, + buttonId, + device.descriptor, + device.name, + mapping.axisCode, + mapping.isPositive, + ) + } + } + } + + private fun getButtonMappings(button: NativeInputButton): Array<InputMapping> { + return when (button) { + VPadButtons.A, + ProControllerButtons.A, + ClassicControllerButtons.A, + WiimoteButtons.A, + -> arrayOf(ButtonInputMapping.BUTTON_A) + + VPadButtons.B, + ProControllerButtons.B, + ClassicControllerButtons.B, + WiimoteButtons.B, + -> arrayOf(ButtonInputMapping.BUTTON_B) + + VPadButtons.X, + ProControllerButtons.X, + ClassicControllerButtons.X, + WiimoteButtons.ONE, + -> arrayOf(ButtonInputMapping.BUTTON_X) + + VPadButtons.Y, + ProControllerButtons.Y, + ClassicControllerButtons.Y, + WiimoteButtons.TWO, + -> arrayOf(ButtonInputMapping.BUTTON_Y) + + VPadButtons.L, + ProControllerButtons.L, + ClassicControllerButtons.L, + WiimoteButtons.NUNCHUCK_C, + -> arrayOf(ButtonInputMapping.BUTTON_L1) + + VPadButtons.R, + ProControllerButtons.R, + ClassicControllerButtons.R, + WiimoteButtons.NUNCHUCK_Z, + -> arrayOf(ButtonInputMapping.BUTTON_R1) + + VPadButtons.ZL, + ProControllerButtons.ZL, + ClassicControllerButtons.ZL, + -> arrayOf(ButtonInputMapping.BUTTON_L2, AxisInputMapping.LTRIGGER) + + VPadButtons.ZR, + ProControllerButtons.ZR, + ClassicControllerButtons.ZR, + -> arrayOf(ButtonInputMapping.BUTTON_R2, AxisInputMapping.RTRIGGER) + + VPadButtons.PLUS, + ProControllerButtons.PLUS, + ClassicControllerButtons.PLUS, + WiimoteButtons.PLUS, + -> arrayOf(ButtonInputMapping.BUTTON_START) + + VPadButtons.MINUS, + ProControllerButtons.MINUS, + ClassicControllerButtons.MINUS, + WiimoteButtons.MINUS, + -> arrayOf(ButtonInputMapping.BUTTON_SELECT) + + VPadButtons.STICKL_UP, + ProControllerButtons.STICKL_UP, + ClassicControllerButtons.STICKL_UP, + WiimoteButtons.NUNCHUCK_UP, + -> arrayOf(AxisInputMapping.Y_NEG) + + VPadButtons.STICKL_DOWN, + ProControllerButtons.STICKL_DOWN, + ClassicControllerButtons.STICKL_DOWN, + WiimoteButtons.NUNCHUCK_DOWN, + -> arrayOf(AxisInputMapping.Y_POS) + + VPadButtons.STICKL_LEFT, + ProControllerButtons.STICKL_LEFT, + ClassicControllerButtons.STICKL_LEFT, + WiimoteButtons.NUNCHUCK_LEFT, + -> arrayOf(AxisInputMapping.X_NEG) + + VPadButtons.STICKL_RIGHT, + ProControllerButtons.STICKL_RIGHT, + ClassicControllerButtons.STICKL_RIGHT, + WiimoteButtons.NUNCHUCK_RIGHT, + -> arrayOf(AxisInputMapping.X_POS) + + VPadButtons.STICKR_UP, + ProControllerButtons.STICKR_UP, + ClassicControllerButtons.STICKR_UP, + -> arrayOf(AxisInputMapping.RY_NEG, AxisInputMapping.RZ_NEG) + + VPadButtons.STICKR_DOWN, + ProControllerButtons.STICKR_DOWN, + ClassicControllerButtons.STICKR_DOWN, + -> arrayOf(AxisInputMapping.RY_POS, AxisInputMapping.RZ_POS) + + VPadButtons.STICKR_LEFT, + ProControllerButtons.STICKR_LEFT, + ClassicControllerButtons.STICKR_LEFT, + -> arrayOf(AxisInputMapping.RX_NEG, AxisInputMapping.Z_NEG) + + VPadButtons.STICKR_RIGHT, + ProControllerButtons.STICKR_RIGHT, + ClassicControllerButtons.STICKR_RIGHT, + -> arrayOf(AxisInputMapping.RX_POS, AxisInputMapping.Z_POS) + + VPadButtons.UP, + ProControllerButtons.UP, + ClassicControllerButtons.UP, + WiimoteButtons.UP, + -> arrayOf(AxisInputMapping.HAT_Y_NEG, ButtonInputMapping.DPAD_UP) + + VPadButtons.DOWN, + ProControllerButtons.DOWN, + ClassicControllerButtons.DOWN, + WiimoteButtons.DOWN, + -> arrayOf(AxisInputMapping.HAT_Y_POS, ButtonInputMapping.DPAD_DOWN) + + VPadButtons.LEFT, + ProControllerButtons.LEFT, + ClassicControllerButtons.LEFT, + WiimoteButtons.LEFT, + -> arrayOf(AxisInputMapping.HAT_X_NEG, ButtonInputMapping.DPAD_LEFT) + + VPadButtons.RIGHT, + ProControllerButtons.RIGHT, + ClassicControllerButtons.RIGHT, + WiimoteButtons.RIGHT, + -> arrayOf(AxisInputMapping.HAT_X_POS, ButtonInputMapping.DPAD_RIGHT) + + VPadButtons.STICKL, + ProControllerButtons.STICKL, + -> arrayOf(ButtonInputMapping.BUTTON_THUMBL) + + VPadButtons.STICKR, + ProControllerButtons.STICKR, + -> arrayOf(ButtonInputMapping.BUTTON_THUMBR) + + else -> arrayOf() + } + } +} + + +private sealed interface InputMapping + +private enum class ButtonInputMapping(val keyCode: Int) : InputMapping { + BUTTON_1(KeyEvent.KEYCODE_BUTTON_1), + BUTTON_2(KeyEvent.KEYCODE_BUTTON_2), + BUTTON_3(KeyEvent.KEYCODE_BUTTON_3), + BUTTON_4(KeyEvent.KEYCODE_BUTTON_4), + BUTTON_5(KeyEvent.KEYCODE_BUTTON_5), + BUTTON_6(KeyEvent.KEYCODE_BUTTON_6), + BUTTON_7(KeyEvent.KEYCODE_BUTTON_7), + BUTTON_8(KeyEvent.KEYCODE_BUTTON_8), + BUTTON_9(KeyEvent.KEYCODE_BUTTON_9), + BUTTON_10(KeyEvent.KEYCODE_BUTTON_10), + BUTTON_11(KeyEvent.KEYCODE_BUTTON_11), + BUTTON_12(KeyEvent.KEYCODE_BUTTON_12), + BUTTON_13(KeyEvent.KEYCODE_BUTTON_13), + BUTTON_14(KeyEvent.KEYCODE_BUTTON_14), + BUTTON_15(KeyEvent.KEYCODE_BUTTON_15), + BUTTON_16(KeyEvent.KEYCODE_BUTTON_16), + BUTTON_A(KeyEvent.KEYCODE_BUTTON_A), + BUTTON_B(KeyEvent.KEYCODE_BUTTON_B), + BUTTON_C(KeyEvent.KEYCODE_BUTTON_C), + BUTTON_L1(KeyEvent.KEYCODE_BUTTON_L1), + BUTTON_L2(KeyEvent.KEYCODE_BUTTON_L2), + BUTTON_MODE(KeyEvent.KEYCODE_BUTTON_MODE), + BUTTON_R1(KeyEvent.KEYCODE_BUTTON_R1), + BUTTON_R2(KeyEvent.KEYCODE_BUTTON_R2), + BUTTON_SELECT(KeyEvent.KEYCODE_BUTTON_SELECT), + BUTTON_START(KeyEvent.KEYCODE_BUTTON_START), + BUTTON_THUMBL(KeyEvent.KEYCODE_BUTTON_THUMBL), + BUTTON_THUMBR(KeyEvent.KEYCODE_BUTTON_THUMBR), + BUTTON_X(KeyEvent.KEYCODE_BUTTON_X), + BUTTON_Y(KeyEvent.KEYCODE_BUTTON_Y), + BUTTON_Z(KeyEvent.KEYCODE_BUTTON_Z), + DPAD_DOWN(KeyEvent.KEYCODE_DPAD_DOWN), + DPAD_LEFT(KeyEvent.KEYCODE_DPAD_LEFT), + DPAD_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT), + DPAD_UP(KeyEvent.KEYCODE_DPAD_UP), +} + +private val FALLBACK_BUTTONS = arrayOf( + ButtonInputMapping.BUTTON_1, + ButtonInputMapping.BUTTON_2, + ButtonInputMapping.BUTTON_3, + ButtonInputMapping.BUTTON_4, + ButtonInputMapping.BUTTON_5, + ButtonInputMapping.BUTTON_6, + ButtonInputMapping.BUTTON_7, + ButtonInputMapping.BUTTON_8, + ButtonInputMapping.BUTTON_9, + ButtonInputMapping.BUTTON_10, + ButtonInputMapping.BUTTON_11, + ButtonInputMapping.BUTTON_12, + ButtonInputMapping.BUTTON_13, + ButtonInputMapping.BUTTON_14, + ButtonInputMapping.BUTTON_15, + ButtonInputMapping.BUTTON_16, +) + +private enum class AxisInputMapping(val axisCode: Int, val isPositive: Boolean) : InputMapping { + HAT_X_POS(MotionEvent.AXIS_HAT_X, true), + HAT_X_NEG(MotionEvent.AXIS_HAT_X, false), + HAT_Y_POS(MotionEvent.AXIS_HAT_Y, true), + HAT_Y_NEG(MotionEvent.AXIS_HAT_Y, false), + RX_POS(MotionEvent.AXIS_RX, true), + RX_NEG(MotionEvent.AXIS_RX, false), + RY_POS(MotionEvent.AXIS_RY, true), + RY_NEG(MotionEvent.AXIS_RY, false), + RZ_POS(MotionEvent.AXIS_RZ, true), + RZ_NEG(MotionEvent.AXIS_RZ, false), + LTRIGGER(MotionEvent.AXIS_LTRIGGER, true), + RTRIGGER(MotionEvent.AXIS_RTRIGGER, true), + X_POS(MotionEvent.AXIS_X, true), + X_NEG(MotionEvent.AXIS_X, false), + Y_POS(MotionEvent.AXIS_Y, true), + Y_NEG(MotionEvent.AXIS_Y, false), + Z_POS(MotionEvent.AXIS_Z, true), + Z_NEG(MotionEvent.AXIS_Z, false), +} + +sealed interface NativeInputButton { + val nativeKeyCode: Int +} + +enum class VPadButtons(override val nativeKeyCode: Int) : NativeInputButton { + A(NativeInput.VPADButton.A), + B(NativeInput.VPADButton.B), + X(NativeInput.VPADButton.X), + Y(NativeInput.VPADButton.Y), + L(NativeInput.VPADButton.L), + R(NativeInput.VPADButton.R), + ZL(NativeInput.VPADButton.ZL), + ZR(NativeInput.VPADButton.ZR), + PLUS(NativeInput.VPADButton.PLUS), + MINUS(NativeInput.VPADButton.MINUS), + UP(NativeInput.VPADButton.UP), + DOWN(NativeInput.VPADButton.DOWN), + LEFT(NativeInput.VPADButton.LEFT), + RIGHT(NativeInput.VPADButton.RIGHT), + STICKL(NativeInput.VPADButton.STICKL), + STICKR(NativeInput.VPADButton.STICKR), + STICKL_UP(NativeInput.VPADButton.STICKL_UP), + STICKL_DOWN(NativeInput.VPADButton.STICKL_DOWN), + STICKL_LEFT(NativeInput.VPADButton.STICKL_LEFT), + STICKL_RIGHT(NativeInput.VPADButton.STICKL_RIGHT), + STICKR_UP(NativeInput.VPADButton.STICKR_UP), + STICKR_DOWN(NativeInput.VPADButton.STICKR_DOWN), + STICKR_LEFT(NativeInput.VPADButton.STICKR_LEFT), + STICKR_RIGHT(NativeInput.VPADButton.STICKR_RIGHT), + MIC(NativeInput.VPADButton.MIC), + SCREEN(NativeInput.VPADButton.SCREEN), + HOME(NativeInput.VPADButton.HOME), +} + +enum class ProControllerButtons(override val nativeKeyCode: Int) : NativeInputButton { + A(NativeInput.ProButton.A), + B(NativeInput.ProButton.B), + X(NativeInput.ProButton.X), + Y(NativeInput.ProButton.Y), + L(NativeInput.ProButton.L), + R(NativeInput.ProButton.R), + ZL(NativeInput.ProButton.ZL), + ZR(NativeInput.ProButton.ZR), + PLUS(NativeInput.ProButton.PLUS), + MINUS(NativeInput.ProButton.MINUS), + HOME(NativeInput.ProButton.HOME), + UP(NativeInput.ProButton.UP), + DOWN(NativeInput.ProButton.DOWN), + LEFT(NativeInput.ProButton.LEFT), + RIGHT(NativeInput.ProButton.RIGHT), + STICKL(NativeInput.ProButton.STICKL), + STICKR(NativeInput.ProButton.STICKR), + STICKL_UP(NativeInput.ProButton.STICKL_UP), + STICKL_DOWN(NativeInput.ProButton.STICKL_DOWN), + STICKL_LEFT(NativeInput.ProButton.STICKL_LEFT), + STICKL_RIGHT(NativeInput.ProButton.STICKL_RIGHT), + STICKR_UP(NativeInput.ProButton.STICKR_UP), + STICKR_DOWN(NativeInput.ProButton.STICKR_DOWN), + STICKR_LEFT(NativeInput.ProButton.STICKR_LEFT), + STICKR_RIGHT(NativeInput.ProButton.STICKR_RIGHT), +} + +enum class ClassicControllerButtons(override val nativeKeyCode: Int) : NativeInputButton { + A(NativeInput.ClassicButton.A), + B(NativeInput.ClassicButton.B), + X(NativeInput.ClassicButton.X), + Y(NativeInput.ClassicButton.Y), + L(NativeInput.ClassicButton.L), + R(NativeInput.ClassicButton.R), + ZL(NativeInput.ClassicButton.ZL), + ZR(NativeInput.ClassicButton.ZR), + PLUS(NativeInput.ClassicButton.PLUS), + MINUS(NativeInput.ClassicButton.MINUS), + HOME(NativeInput.ClassicButton.HOME), + UP(NativeInput.ClassicButton.UP), + DOWN(NativeInput.ClassicButton.DOWN), + LEFT(NativeInput.ClassicButton.LEFT), + RIGHT(NativeInput.ClassicButton.RIGHT), + STICKL_UP(NativeInput.ClassicButton.STICKL_UP), + STICKL_DOWN(NativeInput.ClassicButton.STICKL_DOWN), + STICKL_LEFT(NativeInput.ClassicButton.STICKL_LEFT), + STICKL_RIGHT(NativeInput.ClassicButton.STICKL_RIGHT), + STICKR_UP(NativeInput.ClassicButton.STICKR_UP), + STICKR_DOWN(NativeInput.ClassicButton.STICKR_DOWN), + STICKR_LEFT(NativeInput.ClassicButton.STICKR_LEFT), + STICKR_RIGHT(NativeInput.ClassicButton.STICKR_RIGHT), +} + +enum class WiimoteButtons(override val nativeKeyCode: Int) : NativeInputButton { + A(NativeInput.WiimoteButton.A), + B(NativeInput.WiimoteButton.B), + ONE(NativeInput.WiimoteButton.ONE), + TWO(NativeInput.WiimoteButton.TWO), + NUNCHUCK_Z(NativeInput.WiimoteButton.NUNCHUCK_Z), + NUNCHUCK_C(NativeInput.WiimoteButton.NUNCHUCK_C), + PLUS(NativeInput.WiimoteButton.PLUS), + MINUS(NativeInput.WiimoteButton.MINUS), + UP(NativeInput.WiimoteButton.UP), + DOWN(NativeInput.WiimoteButton.DOWN), + LEFT(NativeInput.WiimoteButton.LEFT), + RIGHT(NativeInput.WiimoteButton.RIGHT), + NUNCHUCK_UP(NativeInput.WiimoteButton.NUNCHUCK_UP), + NUNCHUCK_DOWN(NativeInput.WiimoteButton.NUNCHUCK_DOWN), + NUNCHUCK_LEFT(NativeInput.WiimoteButton.NUNCHUCK_LEFT), + NUNCHUCK_RIGHT(NativeInput.WiimoteButton.NUNCHUCK_RIGHT), + HOME(NativeInput.WiimoteButton.HOME), +} + +fun getNativeButtonsForControllerType(controllerType: Int): Array<NativeInputButton> { + return when (controllerType) { + NativeInput.EmulatedControllerType.VPAD -> VPadButtons.entries.toTypedArray() + NativeInput.EmulatedControllerType.PRO -> ProControllerButtons.entries.toTypedArray() + NativeInput.EmulatedControllerType.CLASSIC -> ClassicControllerButtons.entries.toTypedArray() + NativeInput.EmulatedControllerType.WIIMOTE -> WiimoteButtons.entries.toTypedArray() + else -> arrayOf() + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputSettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputSettingsScreen.kt new file mode 100644 index 00000000..5b8ccd33 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/InputSettingsScreen.kt @@ -0,0 +1,48 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.dropUnlessResumed +import info.cemu.cemu.common.ui.components.Button +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.localization.controllerTypeToString +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput + +data class InputSettingsScreenActions( + val goToInputOverlaySettings: () -> Unit, + val goToControllerSettings: (controllerIndex: Int) -> Unit, +) + +@Composable +fun InputSettingsScreen(navigateBack: () -> Unit, actions: InputSettingsScreenActions) { + val controllers = remember { + (0..<NativeInput.MAX_CONTROLLERS).map { controllerIndex -> + controllerIndex to getControllerType(controllerIndex) + } + } + ScreenContent( + appBarText = tr("Input settings"), + navigateBack = navigateBack, + ) { + Button( + label = tr("Input overlay settings"), + onClick = dropUnlessResumed { actions.goToInputOverlaySettings() }, + ) + controllers.forEach { (controllerIndex, controllerEmulatedType) -> + Button( + label = tr("Controller {0}", controllerIndex + 1), + description = tr( + "Emulated controller: {0}", + controllerTypeToString(controllerEmulatedType) + ), + onClick = dropUnlessResumed { actions.goToControllerSettings(controllerIndex) }, + ) + } + } +} + +fun getControllerType(index: Int): Int = + if (NativeInput.isControllerDisabled(index)) + NativeInput.EmulatedControllerType.DISABLED + else NativeInput.getControllerType(index) diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/ProControllerInputs.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ProControllerInputs.kt new file mode 100644 index 00000000..e5f3aee3 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/ProControllerInputs.kt @@ -0,0 +1,99 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.runtime.Composable +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput.ProButton + +@Composable +fun ProControllerInputs( + onInputClick: (String, Int) -> Unit, + controlsMapping: Map<Int, String>, +) { + @Composable + fun InputItemsGroup( + groupName: String, + inputIds: List<Int>, + ) { + InputItemsGroup( + groupName = groupName, + inputIds = inputIds, + inputIdToString = { proControllerButtonToString(it) }, + onInputClick = onInputClick, + controlsMapping = controlsMapping, + ) + } + InputItemsGroup( + groupName = tr("Buttons"), + inputIds = listOf( + ProButton.A, + ProButton.B, + ProButton.X, + ProButton.Y, + ProButton.L, + ProButton.R, + ProButton.ZL, + ProButton.ZR, + ProButton.PLUS, + ProButton.MINUS, + ProButton.HOME + ) + ) + InputItemsGroup( + groupName = tr("D-pad"), + inputIds = listOf( + ProButton.UP, + ProButton.DOWN, + ProButton.LEFT, + ProButton.RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Left Axis"), + inputIds = listOf( + ProButton.STICKL, + ProButton.STICKL_UP, + ProButton.STICKL_DOWN, + ProButton.STICKL_LEFT, + ProButton.STICKL_RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Right Axis"), + inputIds = listOf( + ProButton.STICKR, + ProButton.STICKR_UP, + ProButton.STICKR_DOWN, + ProButton.STICKR_LEFT, + ProButton.STICKR_RIGHT + ) + ) +} + +private fun proControllerButtonToString(buttonId: Int) = when (buttonId) { + ProButton.A -> "A" + ProButton.B -> "B" + ProButton.X -> "X" + ProButton.Y -> "Y" + ProButton.L -> "L" + ProButton.R -> "R" + ProButton.ZL -> "ZL" + ProButton.ZR -> "ZR" + ProButton.PLUS -> "+" + ProButton.MINUS -> "-" + ProButton.HOME -> tr("home") + ProButton.UP -> tr("up") + ProButton.DOWN -> tr("down") + ProButton.LEFT -> tr("left") + ProButton.RIGHT -> tr("right") + ProButton.STICKL -> tr("click") + ProButton.STICKR -> tr("click") + ProButton.STICKL_UP -> tr("up") + ProButton.STICKL_DOWN -> tr("down") + ProButton.STICKL_LEFT -> tr("left") + ProButton.STICKL_RIGHT -> tr("right") + ProButton.STICKR_UP -> tr("up") + ProButton.STICKR_DOWN -> tr("down") + ProButton.STICKR_LEFT -> tr("left") + ProButton.STICKR_RIGHT -> tr("right") + else -> throw IllegalArgumentException("Invalid buttonId $buttonId for Pro controller type") +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/VPADInputs.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/VPADInputs.kt new file mode 100644 index 00000000..5b92687e --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/VPADInputs.kt @@ -0,0 +1,118 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.runtime.Composable +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput +import info.cemu.cemu.nativeinterface.NativeInput.VPADButton + +@Composable +fun VPADInputs( + controllerIndex: Int, + onInputClick: (String, Int) -> Unit, + controlsMapping: Map<Int, String>, +) { + @Composable + fun InputItemsGroup( + groupName: String, + inputIds: List<Int>, + ) { + InputItemsGroup( + groupName = groupName, + inputIds = inputIds, + inputIdToString = { vpadButtonToString(it) }, + onInputClick = onInputClick, + controlsMapping = controlsMapping, + ) + } + InputItemsGroup( + groupName = tr("Buttons"), + inputIds = listOf( + VPADButton.A, + VPADButton.B, + VPADButton.X, + VPADButton.Y, + VPADButton.L, + VPADButton.R, + VPADButton.ZL, + VPADButton.ZR, + VPADButton.PLUS, + VPADButton.MINUS + ) + ) + InputItemsGroup( + groupName = tr("D-pad"), + inputIds = listOf( + VPADButton.UP, + VPADButton.DOWN, + VPADButton.LEFT, + VPADButton.RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Left Axis"), + inputIds = listOf( + VPADButton.STICKL, + VPADButton.STICKL_UP, + VPADButton.STICKL_DOWN, + VPADButton.STICKL_LEFT, + VPADButton.STICKL_RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Right Axis"), + inputIds = listOf( + VPADButton.STICKR, + VPADButton.STICKR_UP, + VPADButton.STICKR_DOWN, + VPADButton.STICKR_LEFT, + VPADButton.STICKR_RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Extra"), + inputIds = listOf( + VPADButton.MIC, + VPADButton.HOME, + VPADButton.SCREEN + ) + ) + Toggle( + label = tr("Toggle screen"), + description = tr("Makes the \"show screen\" button toggle between the TV and gamepad screens"), + initialCheckedState = { NativeInput.getVPADScreenToggle(controllerIndex) }, + onCheckedChanged = { NativeInput.setVPADScreenToggle(controllerIndex, it) } + ) +} + + +private fun vpadButtonToString(buttonId: Int) = when (buttonId) { + VPADButton.A -> "A" + VPADButton.B -> "B" + VPADButton.X -> "X" + VPADButton.Y -> "Y" + VPADButton.L -> "L" + VPADButton.R -> "R" + VPADButton.ZL -> "ZL" + VPADButton.ZR -> "ZR" + VPADButton.PLUS -> "+" + VPADButton.MINUS -> "-" + VPADButton.UP -> tr("up") + VPADButton.DOWN -> tr("down") + VPADButton.LEFT -> tr("left") + VPADButton.RIGHT -> tr("right") + VPADButton.STICKL -> tr("click") + VPADButton.STICKR -> tr("click") + VPADButton.STICKL_UP -> tr("up") + VPADButton.STICKL_DOWN -> tr("down") + VPADButton.STICKL_LEFT -> tr("left") + VPADButton.STICKL_RIGHT -> tr("right") + VPADButton.STICKR_UP -> tr("up") + VPADButton.STICKR_DOWN -> tr("down") + VPADButton.STICKR_LEFT -> tr("left") + VPADButton.STICKR_RIGHT -> tr("right") + VPADButton.MIC -> tr("blow mic") + VPADButton.SCREEN -> tr("show screen") + VPADButton.HOME -> tr("home") + else -> throw IllegalArgumentException("Invalid buttonId $buttonId for VPAD controller type") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/input/WiimoteControllerInputs.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/input/WiimoteControllerInputs.kt new file mode 100644 index 00000000..fcf4af8c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/input/WiimoteControllerInputs.kt @@ -0,0 +1,78 @@ +package info.cemu.cemu.settings.input + +import androidx.compose.runtime.Composable +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput.WiimoteButton + +@Composable +fun WiimoteControllerInputs( + onInputClick: (String, Int) -> Unit, + controlsMapping: Map<Int, String>, +) { + @Composable + fun InputItemsGroup( + groupName: String, + inputIds: List<Int>, + ) { + InputItemsGroup( + groupName = groupName, + inputIds = inputIds, + inputIdToString = { wiimoteButtonItToString(it) }, + onInputClick = onInputClick, + controlsMapping = controlsMapping, + ) + } + InputItemsGroup( + groupName = tr("Buttons"), + inputIds = listOf( + WiimoteButton.A, + WiimoteButton.B, + WiimoteButton.ONE, + WiimoteButton.TWO, + WiimoteButton.NUNCHUCK_Z, + WiimoteButton.NUNCHUCK_C, + WiimoteButton.PLUS, + WiimoteButton.MINUS, + WiimoteButton.HOME + ) + ) + InputItemsGroup( + groupName = tr("Nunchuck"), + inputIds = listOf( + WiimoteButton.UP, + WiimoteButton.DOWN, + WiimoteButton.LEFT, + WiimoteButton.RIGHT + ) + ) + InputItemsGroup( + groupName = tr("Right Axis"), + inputIds = listOf( + WiimoteButton.NUNCHUCK_UP, + WiimoteButton.NUNCHUCK_DOWN, + WiimoteButton.NUNCHUCK_LEFT, + WiimoteButton.NUNCHUCK_RIGHT + ) + ) +} + +private fun wiimoteButtonItToString(buttonId: Int) = when (buttonId) { + WiimoteButton.A -> "A" + WiimoteButton.B -> "B" + WiimoteButton.ONE -> "1" + WiimoteButton.TWO -> "2" + WiimoteButton.NUNCHUCK_Z -> "Z" + WiimoteButton.NUNCHUCK_C -> "C" + WiimoteButton.PLUS -> "+" + WiimoteButton.MINUS -> "-" + WiimoteButton.UP -> tr("up") + WiimoteButton.DOWN -> tr("down") + WiimoteButton.LEFT -> tr("left") + WiimoteButton.RIGHT -> tr("right") + WiimoteButton.NUNCHUCK_UP -> tr("up") + WiimoteButton.NUNCHUCK_DOWN -> tr("down") + WiimoteButton.NUNCHUCK_LEFT -> tr("left") + WiimoteButton.NUNCHUCK_RIGHT -> tr("right") + WiimoteButton.HOME -> tr("home") + else -> throw IllegalArgumentException("Invalid buttonId $buttonId for Wiimote controller type") +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsScreen.kt new file mode 100644 index 00000000..c3590125 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsScreen.kt @@ -0,0 +1,53 @@ +package info.cemu.cemu.settings.inputoverlay + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.components.Slider +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeInput + +private val ControllerIndexChoices = (0..<NativeInput.MAX_CONTROLLERS).toList() + +@Composable +fun InputOverlaySettingsScreen( + inputOverlaySettingsViewModel: InputOverlaySettingsViewModel = viewModel(), + navigateBack: () -> Unit, +) { + val overlaySettings = inputOverlaySettingsViewModel.overlaySettings + ScreenContent( + appBarText = tr("Input overlay settings"), + navigateBack = navigateBack + ) + { + Toggle( + label = tr("Input overlay"), + description = tr("Enable input overlay"), + initialCheckedState = { overlaySettings.isOverlayEnabled }, + onCheckedChanged = { overlaySettings.isOverlayEnabled = it } + ) + Toggle( + label = tr("Vibrate"), + description = tr("Enable vibrate on touch"), + initialCheckedState = { overlaySettings.isVibrateOnTouchEnabled }, + onCheckedChanged = { overlaySettings.isVibrateOnTouchEnabled = it } + ) + Slider( + label = tr("Inputs opacity"), + initialValue = { overlaySettings.alpha }, + valueFrom = 0, + valueTo = 255, + onValueChange = { overlaySettings.alpha = it }, + labelFormatter = { "${(100 * it) / 255}%" }, + ) + SingleSelection( + label = tr("Overlay controller"), + initialChoice = { overlaySettings.controllerIndex }, + choices = ControllerIndexChoices, + choiceToString = { tr("Controller {0}", it + 1) }, + onChoiceChanged = { overlaySettings.controllerIndex = it } + ) + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsViewModel.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsViewModel.kt new file mode 100644 index 00000000..6c72c57c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/inputoverlay/InputOverlaySettingsViewModel.kt @@ -0,0 +1,8 @@ +package info.cemu.cemu.settings.inputoverlay + +import androidx.lifecycle.ViewModel +import info.cemu.cemu.common.settings.SettingsManager + +class InputOverlaySettingsViewModel : ViewModel() { + val overlaySettings = SettingsManager.inputOverlaySettings +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/cemu/settings/overlay/OverlaySettingsScreen.kt b/src/android/app/src/main/java/info/cemu/cemu/settings/overlay/OverlaySettingsScreen.kt new file mode 100644 index 00000000..7c0bf6f5 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/settings/overlay/OverlaySettingsScreen.kt @@ -0,0 +1,148 @@ +package info.cemu.cemu.settings.overlay + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import info.cemu.cemu.common.ui.components.Header +import info.cemu.cemu.common.ui.components.ScreenContent +import info.cemu.cemu.common.ui.components.SingleSelection +import info.cemu.cemu.common.ui.components.Slider +import info.cemu.cemu.common.ui.components.Toggle +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeSettings + +private val OverlayPositionChoices = listOf( + NativeSettings.OverlayScreenPosition.DISABLED, + NativeSettings.OverlayScreenPosition.TOP_LEFT, + NativeSettings.OverlayScreenPosition.TOP_CENTER, + NativeSettings.OverlayScreenPosition.TOP_RIGHT, + NativeSettings.OverlayScreenPosition.BOTTOM_LEFT, + NativeSettings.OverlayScreenPosition.BOTTOM_CENTER, + NativeSettings.OverlayScreenPosition.BOTTOM_RIGHT +) + +private const val OVERLAY_TEXT_SCALE_STEPS = 9 + +@Composable +fun OverlaySettingsScreen(navigateBack: () -> Unit) { + var overlayPosition by rememberSaveable { mutableIntStateOf(NativeSettings.getOverlayPosition()) } + var notificationsPosition by rememberSaveable { mutableIntStateOf(NativeSettings.getNotificationsPosition()) } + ScreenContent( + appBarText = tr("Overlay settings"), + navigateBack = navigateBack, + ) { + Header(tr("Overlay")) + SingleSelection( + label = tr("Position"), + choice = overlayPosition, + choices = OverlayPositionChoices, + choiceToString = { overlayScreenPositionToString(it) }, + onChoiceChanged = { + overlayPosition = it + NativeSettings.setOverlayPosition(it) + }, + ) + if (overlayPosition != NativeSettings.OverlayScreenPosition.DISABLED) + OverlaySettings() + Header(tr("Notifications")) + SingleSelection( + label = tr("Position"), + choice = notificationsPosition, + choices = OverlayPositionChoices, + choiceToString = { overlayScreenPositionToString(it) }, + onChoiceChanged = { + notificationsPosition = it + NativeSettings.setNotificationsPosition(it) + }, + ) + if (notificationsPosition != NativeSettings.OverlayScreenPosition.DISABLED) + NotificationSettings() + } +} + +@Composable +private fun OverlaySettings() { + Slider( + label = tr("Scale"), + initialValue = NativeSettings::getOverlayTextScalePercentage, + steps = OVERLAY_TEXT_SCALE_STEPS, + valueFrom = NativeSettings.OVERLAY_TEXT_SCALE_MIN, + valueTo = NativeSettings.OVERLAY_TEXT_SCALE_MAX, + onValueChange = NativeSettings::setOverlayTextScalePercentage, + labelFormatter = { "${it}%" } + ) + Toggle( + label = tr("FPS"), + description = tr("The number of frames per second. Average over last 5 seconds"), + initialCheckedState = NativeSettings::isOverlayFPSEnabled, + onCheckedChanged = NativeSettings::setOverlayFPSEnabled, + ) + Toggle( + label = tr("Draw calls per frame"), + description = tr("The number of draw calls per frame. Average over last 5 seconds"), + initialCheckedState = NativeSettings::isOverlayDrawCallsPerFrameEnabled, + onCheckedChanged = NativeSettings::setOverlayDrawCallsPerFrameEnabled, + ) + Toggle( + label = tr("CPU usage"), + description = tr("CPU usage of Cemu in percent"), + initialCheckedState = NativeSettings::isOverlayCPUUsageEnabled, + onCheckedChanged = NativeSettings::setOverlayCPUUsageEnabled, + ) + Toggle( + label = tr("RAM usage"), + description = tr("Cemu RAM usage in MB"), + initialCheckedState = NativeSettings::isOverlayRAMUsageEnabled, + onCheckedChanged = NativeSettings::setOverlayRAMUsageEnabled, + ) + Toggle( + label = tr("Debug"), + description = tr("Displays internal debug information (Vulkan only)"), + initialCheckedState = NativeSettings::isOverlayDebugEnabled, + onCheckedChanged = NativeSettings::setOverlayDebugEnabled, + ) +} + +@Composable +private fun NotificationSettings() { + Slider( + label = tr("Scale"), + initialValue = NativeSettings::getNotificationsTextScalePercentage, + steps = OVERLAY_TEXT_SCALE_STEPS, + valueFrom = NativeSettings.OVERLAY_TEXT_SCALE_MIN, + valueTo = NativeSettings.OVERLAY_TEXT_SCALE_MAX, + onValueChange = NativeSettings::setNotificationsTextScalePercentage, + labelFormatter = { "$it%" } + ) + Toggle( + label = tr("Controller profiles"), + description = tr("Displays the active controller profile when starting a game"), + initialCheckedState = NativeSettings::isNotificationControllerProfilesEnabled, + onCheckedChanged = NativeSettings::setNotificationControllerProfilesEnabled, + ) + Toggle( + label = tr("Shader compiler"), + description = tr("Shows a notification after shaders have been compiled"), + initialCheckedState = NativeSettings::isNotificationShaderCompilerEnabled, + onCheckedChanged = NativeSettings::setNotificationShaderCompilerEnabled, + ) + Toggle( + label = tr("Friend list"), + description = tr("Shows friend list related data if online"), + initialCheckedState = NativeSettings::isNotificationFriendListEnabled, + onCheckedChanged = NativeSettings::setNotificationFriendListEnabled, + ) +} + +private fun overlayScreenPositionToString(overlayScreenPosition: Int) = when (overlayScreenPosition) { + NativeSettings.OverlayScreenPosition.DISABLED -> tr("Disabled") + NativeSettings.OverlayScreenPosition.TOP_LEFT -> tr("Top left") + NativeSettings.OverlayScreenPosition.TOP_CENTER -> tr("Top center") + NativeSettings.OverlayScreenPosition.TOP_RIGHT -> tr("Top right") + NativeSettings.OverlayScreenPosition.BOTTOM_LEFT -> tr("Bottom left") + NativeSettings.OverlayScreenPosition.BOTTOM_CENTER -> tr("Bottom center") + NativeSettings.OverlayScreenPosition.BOTTOM_RIGHT -> tr("Bottom right") + else -> throw IllegalArgumentException("Invalid overlay position: $overlayScreenPosition") +} \ No newline at end of file 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..7c2802e3 --- /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.TitleType.BASE_TITLE_UPDATE -> EntryType.Update + NativeGameTitles.TitleType.AOC -> EntryType.Dlc + NativeGameTitles.TitleType.SYSTEM_OVERLAY_TITLE, NativeGameTitles.TitleType.SYSTEM_DATA, NativeGameTitles.TitleType.SYSTEM_TITLE -> EntryType.System + else -> EntryType.Base +} + +enum class EntryFormat { + SaveFolder, + Folder, + WUD, + NUS, + WUA, + WUHB, +} + +fun nativeTitleFormatToEnum(titleFormat: Int) = when (titleFormat) { + NativeGameTitles.TitleDataFormat.WUD -> EntryFormat.WUD + NativeGameTitles.TitleDataFormat.WIIU_ARCHIVE -> EntryFormat.WUA + NativeGameTitles.TitleDataFormat.NUS -> EntryFormat.NUS + NativeGameTitles.TitleDataFormat.WUHB -> EntryFormat.WUHB + else -> EntryFormat.Folder +} 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..f1b4e085 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleListViewModel.kt @@ -0,0 +1,285 @@ +package info.cemu.cemu.titlemanager + +import android.content.Context +import android.net.Uri +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import info.cemu.cemu.common.collections.toggleInSet +import info.cemu.cemu.nativeinterface.NativeActiveSettings +import info.cemu.cemu.nativeinterface.NativeGameTitles +import info.cemu.cemu.nativeinterface.NativeGameTitles.TitleIdToTitlesCallback.Title +import info.cemu.cemu.titlemanager.usecases.CompressResult +import info.cemu.cemu.titlemanager.usecases.CompressTitleUseCase +import info.cemu.cemu.titlemanager.usecases.DeleteResult +import info.cemu.cemu.titlemanager.usecases.DeleteTitleUseCase +import info.cemu.cemu.titlemanager.usecases.InstallResult +import info.cemu.cemu.titlemanager.usecases.InstallTitleUseCase +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 kotlin.io.path.Path +import kotlin.io.path.relativeToOrNull + +enum class EntryPath { + MLC, + GamePaths, +} + +data class TitleListFilter( + val query: String, + val types: Set<EntryType>, + val formats: Set<EntryFormat>, + val paths: Set<EntryPath>, +) + +class TitleListViewModel : ViewModel() { + private val mlcPath = Path(NativeActiveSettings.getMLCPath()) + private val installUseCase = InstallTitleUseCase(viewModelScope, mlcPath) + private val deleteUseCase = DeleteTitleUseCase(viewModelScope) + private val compressUseCase = CompressTitleUseCase(viewModelScope) + + 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() + + private fun updateFilter(transform: (TitleListFilter) -> TitleListFilter) { + _filter.value = transform(_filter.value) + } + + fun setFilterQuery(query: String) { + updateFilter { it.copy(query = query) } + } + + fun toggleType(type: EntryType) { + updateFilter { it.copy(types = it.types.toggleInSet(type)) } + } + + fun toggleFormat(format: EntryFormat) { + updateFilter { it.copy(formats = it.formats.toggleInSet(format)) } + } + + fun togglePath(path: EntryPath) { + updateFilter { it.copy(paths = it.paths.toggleInSet(path)) } + } + + 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, + callback: (DeleteResult) -> Unit, + ) { + if (_titleToBeDeleted.value != null) + return + + if (!_titleEntries.value.any { it.locationUID == titleEntry.locationUID }) + return + + _titleToBeDeleted.value = titleEntry + + deleteUseCase.delete( + context = context, + titleEntry = titleEntry, + callback = { result -> + if (result == DeleteResult.FINISHED) { + _titleEntries.value = _titleEntries.value.filterNot { + it.locationUID == titleEntry.locationUID && it.path == titleEntry.path + } + } + + _titleToBeDeleted.value = null + + callback(result) + } + ) + } + + 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 + } + + + val titleInstallInProgress = installUseCase.inProgress + val titleInstallProgress = installUseCase.progress + + fun installQueuedTitle(context: Context, callback: (InstallResult) -> Unit) { + val (titleUri, titleExistsStatus) = _queuedTitleToInstall.value ?: return + _queuedTitleToInstall.value = null + + installUseCase.install( + context = context, + titleUri = titleUri, + targetLocation = titleExistsStatus.targetLocation, + callback = callback, + ) + } + + fun cancelInstall() = installUseCase.cancel() + + 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 + } + + val compressInProgress = compressUseCase.inProgress + val compressProgress = compressUseCase.progress + + + fun compressQueuedTitle( + context: Context, + uri: Uri, + onResult: (CompressResult) -> Unit + ) { + _queuedTitleToCompress.value = null + compressUseCase.compress(context, uri, onResult) + } + + fun cancelCompression() = compressUseCase.cancel() + + fun getCompressedFileNameForQueuedTitle() = + NativeGameTitles.getCompressedFileNameForQueuedTitle() + + companion object { + private const val REFRESH_DEBOUNCE_TIME_MILLISECONDS = 1500L + } +} + +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), +) diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerNavigation.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerNavigation.kt new file mode 100644 index 00000000..d28fc7b7 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerNavigation.kt @@ -0,0 +1,15 @@ +package info.cemu.cemu.titlemanager + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +object TitleManagerRoute + +fun NavGraphBuilder.titleManagerNavigation(navController: NavHostController) { + composable<TitleManagerRoute> { + TitleManagerScreen({ navController.popBackStack() }) + } +} 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..27c1e1d2 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/TitleManagerScreen.kt @@ -0,0 +1,835 @@ +@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.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.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.TextField +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.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.common.ui.components.ScreenContentLazy +import info.cemu.cemu.common.ui.components.formatBytes +import info.cemu.cemu.common.ui.localization.regionToString +import info.cemu.cemu.common.ui.localization.tr +import info.cemu.cemu.nativeinterface.NativeGameTitles +import info.cemu.cemu.titlemanager.usecases.CompressResult +import info.cemu.cemu.titlemanager.usecases.DeleteResult +import info.cemu.cemu.titlemanager.usecases.InstallResult +import kotlinx.coroutines.launch +import java.text.MessageFormat + +@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(text: String) { + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(text) + } + } + + fun installQueuedTitle() { + titleListViewModel.installQueuedTitle( + context = context, + callback = { + when (it) { + InstallResult.ERROR -> showNotificationMessage(tr("Error installing")) + InstallResult.FINISHED -> showNotificationMessage(tr("Finished installing")) + InstallResult.NOT_ENOUGH_SPACE -> showNotificationMessage(tr("Not enough space")) + } + }, + ) + } + + 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(tr("Invalid title")) + }) + } + + val compressFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + titleListViewModel.compressQueuedTitle( + context = context, + uri = uri, + onResult = { result -> + { + when (result) { + CompressResult.FINISHED -> showNotificationMessage(tr("Finished converting")) + CompressResult.ERROR -> showNotificationMessage(tr("Error while converting")) + } + } + } + ) + } + + ScreenContentLazy( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + appBarText = tr("Title manager"), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { + titleListViewModel.refresh() + showNotificationMessage(tr("Refreshing titles")) + }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null + ) + } + IconButton(onClick = { showFilterSheet = true }) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = null + ) + } + IconButton(onClick = { installTitleLauncher.launch(null) }) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null + ) + } + } + ) { + items(items = titleEntries, key = { it.locationUID }) { + TitleEntryListItem( + titleEntry = it, + onDeleteRequest = { + titleListViewModel.deleteTitleEntry( + titleEntry = it, + context = context, + callback = { result -> + when (result) { + DeleteResult.FINISHED -> showNotificationMessage(tr("Deleted title entry")) + DeleteResult.ERROR -> showNotificationMessage(tr("Failed to delete title entry")) + } + }) + }, + 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 rememberSaveable { mutableStateOf(false) } + + AlertDialog( + title = { Text(tr("Compressing title")) }, + text = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator() + if (bytesWritten != null) + Text(tr("Current progress: {0}", bytesWritten.formatBytes())) + } + }, + onDismissRequest = {}, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = { showCancelConfirmDialog = true }, + content = { Text(tr("Cancel")) }, + ) + } + ) + + if (showCancelConfirmDialog) + AlertDialog( + title = { Text(tr("Cancel compressing title")) }, + text = { Text(tr("Do you really want to cancel compressing the title?")) }, + onDismissRequest = { showCancelConfirmDialog = false }, + confirmButton = { + TextButton(onClick = onCancel) { + Text(tr("Yes")) + } + }, + dismissButton = { + TextButton(onClick = { showCancelConfirmDialog = false }) { + Text(tr("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 ?: tr("Not installed") + ) + } + AlertDialog( + title = { Text(tr("Confirmation")) }, + text = { + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + text = tr("The following content will be converted to a compressed Wii U archive file (.wua)"), + ) + EntryInfo( + tr("Base game"), + compressTitleInfo.basePrintPath + ) + EntryInfo( + tr("Update"), + compressTitleInfo.updatePrintPath + ) + EntryInfo( + tr("DLC"), + compressTitleInfo.aocPrintPath + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { Text(tr("OK")) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(tr("Cancel")) } + } + ) +} + +@Composable +private fun TitleInstallProgressDialog( + progress: Pair<Long, Long>?, + onCancel: () -> Unit, +) { + var showCancelConfirmDialog by rememberSaveable { mutableStateOf(false) } + + AlertDialog( + title = { Text(tr("Installing title")) }, + text = { + val progressModifiers = Modifier + .fillMaxWidth() + .padding(8.dp) + Column { + if (progress == null) { + LinearProgressIndicator(modifier = progressModifiers) + Text( + modifier = Modifier.padding(8.dp), + text = tr("Parsing title 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(tr("Cancel")) + } + } + ) + + if (showCancelConfirmDialog) + AlertDialog( + title = { Text(tr("Cancel installing title")) }, + text = { + Text(tr("Do you really want to cancel the installation process?\n\nCanceling the process will delete the applied files.")) + }, + onDismissRequest = { showCancelConfirmDialog = false }, + confirmButton = { + TextButton(onClick = onCancel) { + Text(tr("Yes")) + } + }, + dismissButton = { + TextButton(onClick = { showCancelConfirmDialog = false }) { + Text(tr("No")) + } + } + ) +} + + +@Composable +private fun TitleInstallConfirmDialog( + error: NativeGameTitles.TitleExistsError, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + + val errorMessage = when (error) { + is NativeGameTitles.TitleExistsError.DifferentType -> tr( + """It seems that there is already a title installed at the target location but it has a different type. +Currently installed: '{0}' Installing: '{1}' +Do you still want to continue with the installation? It will replace the currently installed title.""", + error.oldType, + error.toInstallType + ) + + NativeGameTitles.TitleExistsError.NewVersion -> tr("It seems that a newer version is already installed, do you still want to install the older version?") + NativeGameTitles.TitleExistsError.SameVersion -> tr("It seems that the selected title is already installed, do you want to reinstall it?") + + NativeGameTitles.TitleExistsError.None -> { + onConfirm() + return + } + } + + AlertDialog( + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { Text(tr("Warning")) }, + text = { + Text( + text = errorMessage, + modifier = Modifier, + ) + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = onConfirm) { Text(tr("Yes")) } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(tr("No")) } + } + ) +} + +@Composable +private fun TitleFilterBottomSheet( + onDismissRequest: () -> Unit, + titleListViewModel: TitleListViewModel, +) { + val filter by titleListViewModel.filter.collectAsState() + + ModalBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = onDismissRequest, + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + TextField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + singleLine = true, + value = filter.query, + onValueChange = titleListViewModel::setFilterQuery, + label = { Text(tr("Search titles")) } + ) + FilterRow( + filterRowLabel = tr("Types"), + filterValues = EntryType.entries.map { (it to (it in filter.types)) }, + valueToLabel = { entryTypeToString(it) }, + onToggle = titleListViewModel::toggleType, + ) + FilterRow( + filterRowLabel = tr("Formats"), + filterValues = EntryFormat.entries.map { (it to (it in filter.formats)) }, + valueToLabel = { formatToString(it) }, + onToggle = titleListViewModel::toggleFormat, + ) + FilterRow( + filterRowLabel = tr("Locations"), + filterValues = EntryPath.entries.map { (it to (it in filter.paths)) }, + valueToLabel = { pathToString(it) }, + onToggle = titleListViewModel::togglePath, + ) + } + } +} + +@Composable +private fun <T : Enum<T>> FilterRow( + filterRowLabel: String, + filterValues: List<Pair<T, Boolean>>, + valueToLabel: @Composable (T) -> String, + onToggle: (T) -> Unit +) { + var showOptions by rememberSaveable { 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, + onToggle = { onToggle(value) }, + ) + } + } + } +} + +@Composable +fun FilterChip(label: String, selected: Boolean, onToggle: () -> Unit) { + FilterChip( + leadingIcon = { + if (selected) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null + ) + }, + selected = selected, + onClick = { + onToggle() + }, + 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 rememberSaveable { 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 = null + ) + } + } + 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 rememberSaveable { 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 = null + ) + } + DropdownMenu( + expanded = expandMenu, + onDismissRequest = { expandMenu = false }) { + DropdownMenuItem(tr("Delete"), onDeleteClicked) + if (titleEntry.type != EntryType.Save && titleEntry.format != EntryFormat.WUA) + DropdownMenuItem( + tr("Convert to WUA"), + onCompressClicked + ) + } + } +} + +@Composable +private fun DeleteTitleProgressDialog(titleEntry: TitleEntry) { + AlertDialog( + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { + Text(tr("Deleting")) + }, + text = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(tr("Deleting: {0}", getTitleEntryInfo(titleEntry))) + 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(tr("Warning")) }, + text = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(tr("Are you really sure you want to delete the following entry?")) + Text(getTitleEntryInfo(titleEntry)) + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirmDelete() + }, + content = { Text(tr("Yes")) }, + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(tr("No")) } + } + ) +} + +private fun getTitleEntryInfo(titleEntry: TitleEntry) = MessageFormat.format( + "[{0}] [{1}] [{2}]", + titleEntry.name, + regionToString(titleEntry.region), + entryTypeToString(titleEntry.type) +) + +@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 = null + ) +} + +@Composable +private fun TitleEntryData(titleEntry: TitleEntry) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + TitleEntryInfo( + name = tr("Title ID"), + value = formatTitleId(titleEntry.titleId) + ) + TitleEntryInfo( + name = tr("Type"), + value = entryTypeToString(titleEntry.type), + ) + TitleEntryInfo( + name = tr("Version"), + value = titleEntry.version.toString(), + ) + TitleEntryInfo( + name = tr("Region"), + value = regionToString(titleEntry.region), + ) + TitleEntryInfo( + name = tr("Format"), + value = formatToString(titleEntry.format), + ) + TitleEntryInfo( + name = tr("Location"), + value = if (titleEntry.isInMLC) tr("MLC") + else tr("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 + ) +} + +private fun formatToString(entryFormat: EntryFormat) = when (entryFormat) { + EntryFormat.Folder -> tr("Folder") + EntryFormat.WUD -> tr("WUD") + EntryFormat.NUS -> tr("NUS") + EntryFormat.WUA -> tr("WUA") + EntryFormat.WUHB -> tr("WUHB") + EntryFormat.SaveFolder -> tr("Save folder") +} + +private fun pathToString(entryPath: EntryPath) = when (entryPath) { + EntryPath.MLC -> tr("MLC") + EntryPath.GamePaths -> tr("Game paths") +} + +private fun entryTypeToString(entryType: EntryType) = when (entryType) { + EntryType.Base -> tr("Base") + EntryType.Update -> tr("Update") + EntryType.Dlc -> tr("DLC") + EntryType.Save -> tr("Save") + EntryType.System -> tr("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/titlemanager/usecases/CompressTitleUseCase.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/CompressTitleUseCase.kt new file mode 100644 index 00000000..e40c2a7c --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/CompressTitleUseCase.kt @@ -0,0 +1,87 @@ +package info.cemu.cemu.titlemanager.usecases + +import android.content.Context +import android.net.Uri +import info.cemu.cemu.nativeinterface.NativeGameTitles +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +private typealias NativeCompressResult = NativeGameTitles.CompressResult + +enum class CompressResult { + FINISHED, + ERROR, +} + +class CompressTitleUseCase(private val scope: CoroutineScope) { + private val _inProgress = MutableStateFlow(false) + val inProgress = _inProgress.asStateFlow() + + private val _progress = MutableStateFlow<Long?>(null) + val progress = _progress.asStateFlow() + + private var compressJob: Job? = null + private var progressJob: Job? = null + + + fun cancel() { + scope.launch(Dispatchers.IO) { + progressJob?.cancelAndJoin() + NativeGameTitles.cancelTitleCompression() + _inProgress.value = false + _progress.value = null + } + } + + fun compress( + context: Context, + uri: Uri, + callback: (CompressResult) -> Unit + ) { + if (_inProgress.value) return + + val fd = context.contentResolver.openFileDescriptor(uri, "rw") + + if (fd == null) { + callback(CompressResult.ERROR) + return + } + + val oldProgressJob = progressJob + compressJob = scope.launch { + oldProgressJob?.cancelAndJoin() + _inProgress.value = true + + try { + progressJob = launch { + while (isActive) { + delay(500) + _progress.value = NativeGameTitles.getCurrentProgressForCompression() + } + } + + NativeGameTitles.compressQueuedTitle( + fd = fd.detachFd(), + callback = { result -> + when (result) { + NativeCompressResult.FINISHED -> callback(CompressResult.FINISHED) + NativeCompressResult.ERROR -> callback(CompressResult.ERROR) + } + + progressJob?.cancel() + _inProgress.value = false + _progress.value = null + } + ) + } catch (_: Exception) { + progressJob?.cancel() + + _inProgress.value = false + _progress.value = null + + callback(CompressResult.ERROR) + } + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/DeleteTitleUseCase.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/DeleteTitleUseCase.kt new file mode 100644 index 00000000..03a0eb1f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/DeleteTitleUseCase.kt @@ -0,0 +1,50 @@ +package info.cemu.cemu.titlemanager.usecases + +import android.content.ContentResolver +import android.content.Context +import android.provider.DocumentsContract +import info.cemu.cemu.common.string.isContentUri +import info.cemu.cemu.nativeinterface.fromNativePath +import info.cemu.cemu.titlemanager.TitleEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.io.path.Path + +enum class DeleteResult +{ + FINISHED, + ERROR, +} + +class DeleteTitleUseCase( + private val scope: CoroutineScope +) { + fun delete( + context: Context, + titleEntry: TitleEntry, + callback: (DeleteResult) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + try { + if (delete(context.contentResolver, titleEntry.path)) + callback(DeleteResult.FINISHED) + else + callback(DeleteResult.ERROR) + } catch (_: Exception) { + callback(DeleteResult.ERROR) + } + } + } + + private fun delete(contentResolver: ContentResolver, path: String): Boolean { + if (path.isContentUri()) { + return DocumentsContract.deleteDocument( + contentResolver, + path.fromNativePath() + ) + } + + return Path(path).toFile().deleteRecursively() + } +} diff --git a/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/InstallTitleUseCase.kt b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/InstallTitleUseCase.kt new file mode 100644 index 00000000..fbe402f5 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/cemu/titlemanager/usecases/InstallTitleUseCase.kt @@ -0,0 +1,200 @@ +package info.cemu.cemu.titlemanager.usecases + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import info.cemu.cemu.common.android.contentresolver.DocumentEntry +import info.cemu.cemu.common.android.contentresolver.walkDocumentTree +import info.cemu.cemu.common.io.copyInputStreamToFile +import info.cemu.cemu.common.string.urlDecode +import info.cemu.cemu.nativeinterface.NativeGameTitles +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +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.math.max +import kotlin.random.Random +import kotlin.random.nextUInt + +enum class InstallResult{ + ERROR, + FINISHED, + NOT_ENOUGH_SPACE, +} + +private sealed class DirEntry { + data class File(val uri: Uri, val destinationPath: Path, val size: Long) : DirEntry() + data class Dir(val destinationPath: Path) : DirEntry() +} + +class InstallTitleUseCase( + private val scope: CoroutineScope, + private val mlcPath: Path +) { + private val _inProgress = MutableStateFlow(false) + val inProgress: StateFlow<Boolean> = _inProgress + + private val _progress = MutableStateFlow<Pair<Long, Long>?>(null) + val progress: StateFlow<Pair<Long, Long>?> = _progress + + private var installJob: Job? = null + private var cleanupJob: Job? = null + + fun cancel() { + installJob?.cancel() + installJob = null + } + + fun install( + context: Context, + titleUri: Uri, + targetLocation: String, + callback: (InstallResult) -> Unit + ) { + if (_inProgress.value) return + + val installPath = Path(targetLocation) + val installFile = installPath.toFile() + val backupFile = installPath.getBackupFile() + + _progress.value = null + _inProgress.value = true + + val oldInstallJob = installJob + installJob = scope.launch(Dispatchers.IO) { + var installStarted = false + + try { + cleanupJob?.join() + oldInstallJob?.join() + val contentResolver = context.contentResolver + val buffer = ByteArray(8192) + + val (totalSize, entries) = listFilesInSourceDirs( + contentResolver = contentResolver, + titleDir = DocumentFile.fromTreeUri(context, titleUri)!!, + titleUri = titleUri, + targetLocation = targetLocation, + ) + + if (totalSize > mlcPath.toFile().freeSpace) { + callback(InstallResult.NOT_ENOUGH_SPACE) + return@launch + } + + backupFile.deleteRecursively() + if (installFile.exists()) + installFile.renameTo(backupFile) + + installStarted = true + _progress.value = 0L to totalSize + var bytesWritten = 0L + + 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.size + _progress.value = bytesWritten to totalSize + } + } + } + + if (backupFile.exists()) + backupFile.deleteRecursively() + + NativeGameTitles.addTitleFromPath(targetLocation) + + callback(InstallResult.FINISHED) + } catch (e: Exception) { + if (installStarted) + cleanupInstall(installFile) + + if (e !is CancellationException) callback(InstallResult.ERROR) + } finally { + _inProgress.value = false + } + } + } + + 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)) + contentResolver.walkDocumentTree( + dirUri = parentUri, + onEntry = { + when (it) { + is DocumentEntry.Directory -> { + entries += DirEntry.Dir(uriToTargetPath(it.uri)) + } + + is DocumentEntry.File -> { + totalSize += it.size + entries += DirEntry.File( + it.uri, + uriToTargetPath(it.uri), + it.size + ) + } + } + }, + ) + } + + totalSize = max(totalSize, 1L) + + return Pair(totalSize, entries) + } + + private fun cleanupInstall(installFile: File) { + val oldCleanupJob = cleanupJob + cleanupJob = scope.launch(Dispatchers.IO) { + oldCleanupJob?.join() + 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() + } + } + + companion object { + private val SOURCE_DIRS = arrayOf("content", "code", "meta") + private fun Path.getBackupFile() = resolveSibling("$fileName.backup").toFile() + } +} diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..40c23537 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_box.xml b/src/android/app/src/main/res/drawable/ic_box.xml new file mode 100644 index 00000000..e9335e69 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_box.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M200,320L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,320L640,320L640,640L480,560L320,640L320,320L200,320ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,261Q120,247 124.5,234Q129,221 138,210L188,149Q199,135 215.5,127.5Q232,120 250,120L710,120Q728,120 744.5,127.5Q761,135 772,149L822,210Q831,221 835.5,234Q840,247 840,261L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM216,240L744,240L710,200Q710,200 710,200Q710,200 710,200L250,200Q250,200 250,200Q250,200 250,200L216,240ZM400,320L400,510L480,470L560,510L560,320L400,320ZM200,320L320,320L320,320L480,320L640,320L640,320L760,320L760,320Q760,320 760,320Q760,320 760,320L200,320Q200,320 200,320Q200,320 200,320Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_build.xml b/src/android/app/src/main/res/drawable/ic_build.xml new file mode 100644 index 00000000..9c576818 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_build.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M686,828L444,584Q424,592 403.5,596Q383,600 360,600Q260,600 190,530Q120,460 120,360Q120,324 130,291.5Q140,259 158,230L304,376L376,304L230,158Q259,140 291.5,130Q324,120 360,120Q460,120 530,190Q600,260 600,360Q600,383 596,403.5Q592,424 584,444L828,686Q840,698 840,715Q840,732 828,744L744,828Q732,840 715,840Q698,840 686,828ZM715,743L742,716L486,460Q504,440 512,413.5Q520,387 520,360Q520,300 481.5,255.5Q443,211 386,202L460,276Q472,288 472,304Q472,320 460,332L332,460Q320,472 304,472Q288,472 276,460L202,386Q211,443 255.5,481.5Q300,520 360,520Q386,520 412,512Q438,504 459,487L715,743ZM472,472L472,472Q472,472 472,472Q472,472 472,472Q472,472 472,472Q472,472 472,472L472,472Q472,472 472,472Q472,472 472,472L472,472Q472,472 472,472Q472,472 472,472L472,472Q472,472 472,472Q472,472 472,472Q472,472 472,472Q472,472 472,472L472,472Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_check.xml b/src/android/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..a0ac8750 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M382,752L122,492L212,402L382,572L748,206L838,296L382,752Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_chevron_right.xml b/src/android/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 00000000..94f20603 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:autoMirrored="true" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_controller.xml b/src/android/app/src/main/res/drawable/ic_controller.xml new file mode 100644 index 00000000..eb700e55 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_controller.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M189,800Q129,800 86.5,757Q44,714 42,653Q42,644 43,635Q44,626 46,617L130,281Q144,227 187,193.5Q230,160 285,160L675,160Q730,160 773,193.5Q816,227 830,281L914,617Q916,626 917.5,635.5Q919,645 919,654Q919,715 875.5,757.5Q832,800 771,800Q729,800 693,778Q657,756 639,718L611,660Q606,650 596,645Q586,640 575,640L385,640Q374,640 364,645Q354,650 349,660L321,718Q303,756 267,778Q231,800 189,800ZM192,720Q211,720 226.5,710Q242,700 250,683L278,626Q293,595 322,577.5Q351,560 385,560L575,560Q609,560 638,578Q667,596 683,626L711,683Q719,700 734.5,710Q750,720 769,720Q797,720 817,701.5Q837,683 838,655Q838,656 836,636L752,301Q745,274 724,257Q703,240 675,240L285,240Q257,240 235.5,257Q214,274 208,301L124,636Q122,642 122,654Q122,682 142.5,701Q163,720 192,720ZM540,440Q557,440 568.5,428.5Q580,417 580,400Q580,383 568.5,371.5Q557,360 540,360Q523,360 511.5,371.5Q500,383 500,400Q500,417 511.5,428.5Q523,440 540,440ZM620,360Q637,360 648.5,348.5Q660,337 660,320Q660,303 648.5,291.5Q637,280 620,280Q603,280 591.5,291.5Q580,303 580,320Q580,337 591.5,348.5Q603,360 620,360ZM620,520Q637,520 648.5,508.5Q660,497 660,480Q660,463 648.5,451.5Q637,440 620,440Q603,440 591.5,451.5Q580,463 580,480Q580,497 591.5,508.5Q603,520 620,520ZM700,440Q717,440 728.5,428.5Q740,417 740,400Q740,383 728.5,371.5Q717,360 700,360Q683,360 671.5,371.5Q660,383 660,400Q660,417 671.5,428.5Q683,440 700,440ZM340,500Q353,500 361.5,491.5Q370,483 370,470L370,430L410,430Q423,430 431.5,421.5Q440,413 440,400Q440,387 431.5,378.5Q423,370 410,370L370,370L370,330Q370,317 361.5,308.5Q353,300 340,300Q327,300 318.5,308.5Q310,317 310,330L310,370L270,370Q257,370 248.5,378.5Q240,387 240,400Q240,413 248.5,421.5Q257,430 270,430L310,430L310,470Q310,483 318.5,491.5Q327,500 340,500ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_delete.xml b/src/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..d7de5795 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_download.xml b/src/android/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..e2c863fa --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_favorite.xml b/src/android/app/src/main/res/drawable/ic_favorite.xml new file mode 100644 index 00000000..a2580833 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_favorite.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M320,720L480,598L640,720L580,522L740,408L544,408L480,200L416,408L220,408L380,522L320,720ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_filter.xml b/src/android/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..32654488 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_launcher_background.xml b/src/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..9335161a --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,7 @@ +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <gradient + android:angle="90" + android:endColor="#00b5ff" + android:startColor="#0087b5" /> +</shape> \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_lists.xml b/src/android/app/src/main/res/drawable/ic_lists.xml new file mode 100644 index 00000000..55d16002 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_lists.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:autoMirrored="true" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M80,800L80,640L240,640L240,800L80,800ZM320,800L320,640L880,640L880,800L320,800ZM80,560L80,400L240,400L240,560L80,560ZM320,560L320,400L880,400L880,560L320,560ZM80,320L80,160L240,160L240,320L80,320ZM320,320L320,160L880,160L880,320L320,320Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_move.xml b/src/android/app/src/main/res/drawable/ic_move.xml new file mode 100644 index 00000000..a1909388 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_move.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,880L310,710L367,653L440,726L440,520L235,520L308,592L250,650L80,480L249,311L306,368L234,440L440,440L440,234L367,307L310,250L480,80L650,250L593,307L520,234L520,440L725,440L652,368L710,310L880,480L710,650L653,593L726,520L520,520L520,725L592,652L650,710L480,880Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_package_2.xml b/src/android/app/src/main/res/drawable/ic_package_2.xml new file mode 100644 index 00000000..a1e30294 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_package_2.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,777L440,503L200,364L200,638Q200,638 200,638Q200,638 200,638L440,777ZM520,777L760,638Q760,638 760,638Q760,638 760,638L760,364L520,503L520,777ZM440,869L160,708Q141,697 130.5,679Q120,661 120,639L120,321Q120,299 130.5,281Q141,263 160,252L440,91Q459,80 480,80Q501,80 520,91L800,252Q819,263 829.5,281Q840,299 840,321L840,639Q840,661 829.5,679Q819,697 800,708L520,869Q501,880 480,880Q459,880 440,869ZM640,341L717,297L480,160Q480,160 480,160Q480,160 480,160L402,205L640,341ZM480,434L558,389L321,252L243,297L480,434Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_question_mark.xml b/src/android/app/src/main/res/drawable/ic_question_mark.xml new file mode 100644 index 00000000..2d20020b --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_question_mark.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M424,640Q424,559 438.5,523.5Q453,488 500,446Q541,410 562.5,383.5Q584,357 584,323Q584,282 556.5,255Q529,228 480,228Q429,228 402.5,259Q376,290 365,322L262,278Q283,214 339,167Q395,120 480,120Q585,120 641.5,178.5Q698,237 698,319Q698,369 676.5,404.5Q655,440 609,485Q560,532 549.5,556.5Q539,581 539,640L424,640ZM480,880Q447,880 423.5,856.5Q400,833 400,800Q400,767 423.5,743.5Q447,720 480,720Q513,720 536.5,743.5Q560,767 560,800Q560,833 536.5,856.5Q513,880 480,880Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_resize.xml b/src/android/app/src/main/res/drawable/ic_resize.xml new file mode 100644 index 00000000..bd0461bf --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_resize.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M120,840L120,520L200,520L200,704L704,200L520,200L520,120L840,120L840,440L760,440L760,256L256,760L440,760L440,840L120,840Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_restart.xml b/src/android/app/src/main/res/drawable/ic_restart.xml new file mode 100644 index 00000000..dbd38d71 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_restart.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,838Q319,823 239.5,732.5Q160,642 160,520Q160,454 186,393.5Q212,333 260,288L317,345Q279,379 259.5,424Q240,469 240,520Q240,608 296,675.5Q352,743 440,758L440,838ZM520,838L520,758Q607,742 663.5,675Q720,608 720,520Q720,420 650,350Q580,280 480,280L477,280L521,324L465,380L325,240L465,100L521,156L477,200L480,200Q614,200 707,293Q800,386 800,520Q800,641 720.5,731.5Q641,822 520,838Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..02cb0f71 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 00000000..0db52578 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M796,839L533,576Q503,602 463.04,616.5Q423.08,631 378,631Q269.84,631 194.92,556Q120,481 120,375Q120,269 195,194Q270,119 376.5,119Q483,119 557.5,194Q632,269 632,375.15Q632,418 618,458Q604,498 576,533L840,795L796,839ZM377,571Q458.25,571 515.13,513.5Q572,456 572,375Q572,294 515.13,236.5Q458.25,179 377,179Q294.92,179 237.46,236.5Q180,294 180,375Q180,456 237.46,513.5Q294.92,571 377,571Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_settings.xml b/src/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..3477b4b7 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M388,880L368,754Q349,747 328,735Q307,723 291,710L173,764L80,600L188,521Q186,512 185.5,500.5Q185,489 185,480Q185,471 185.5,459.5Q186,448 188,439L80,360L173,196L291,250Q307,237 328,225Q349,213 368,207L388,80L572,80L592,206Q611,213 632.5,224.5Q654,236 669,250L787,196L880,360L772,437Q774,447 774.5,458.5Q775,470 775,480Q775,490 774.5,501Q774,512 772,522L880,600L787,764L669,710Q653,723 632.5,735.5Q612,748 592,754L572,880L388,880ZM480,610Q534,610 572,572Q610,534 610,480Q610,426 572,388Q534,350 480,350Q426,350 388,388Q350,426 350,480Q350,534 388,572Q426,610 480,610ZM480,550Q451,550 430.5,529.5Q410,509 410,480Q410,451 430.5,430.5Q451,410 480,410Q509,410 529.5,430.5Q550,451 550,480Q550,509 529.5,529.5Q509,550 480,550ZM480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480ZM436,820L524,820L538,708Q571,700 600.5,683Q630,666 654,642L760,688L800,616L706,547Q710,530 712.5,513.5Q715,497 715,480Q715,463 713,446.5Q711,430 706,413L800,344L760,272L654,318Q631,292 602,274.5Q573,257 538,252L524,140L436,140L422,252Q388,259 358.5,276Q329,293 306,318L200,272L160,344L254,413Q250,430 247.5,446.5Q245,463 245,480Q245,497 247.5,513.5Q250,530 254,547L160,616L200,688L306,642Q330,666 359.5,683Q389,700 422,708L436,820Z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_upgrade.xml b/src/android/app/src/main/res/drawable/ic_upgrade.xml new file mode 100644 index 00000000..fcfd2b3e --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_upgrade.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M280,800L280,720L680,720L680,800L280,800ZM440,640L440,313L336,416L280,360L480,160L680,360L624,416L520,313L520,640L440,640Z"/> +</vector> diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml new file mode 100644 index 00000000..848217e0 --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="utf-8"?> +<info.cemu.cemu.common.ui.components.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/drawer_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:touchscreenBlocksFocus="true" + tools:openDrawer="start"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:id="@+id/canvases_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="horizontal"> + + <SurfaceView + android:id="@+id/main_canvas" + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" /> + </LinearLayout> + + <info.cemu.cemu.emulation.inputoverlay.InputOverlaySurfaceView + android:id="@+id/input_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <Button + android:id="@+id/finish_edit_inputs_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:visibility="gone" /> + + <LinearLayout + android:id="@+id/edit_inputs_layout" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerHorizontal="true" + android:layout_margin="8dp" + android:gravity="center" + android:visibility="gone"> + + <Button + android:id="@+id/move_inputs_button" + style="?attr/materialIconButtonFilledStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + app:icon="@drawable/ic_move" /> + + <Button + android:id="@+id/resize_inputs_button" + style="?attr/materialIconButtonFilledStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:icon="@drawable/ic_resize" /> + </LinearLayout> + </RelativeLayout> + + <include + android:id="@+id/side_menu" + layout="@layout/layout_side_menu_emulation" /> + +</info.cemu.cemu.common.ui.components.DrawerLayout> diff --git a/src/android/app/src/main/res/layout/layout_emulation_input.xml b/src/android/app/src/main/res/layout/layout_emulation_input.xml new file mode 100644 index 00000000..bf382d0e --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_emulation_input.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/emulation_input_layout" + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp"> + + <info.cemu.cemu.emulation.EmulationTextInputEditText + android:id="@+id/emulation_input_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> +</com.google.android.material.textfield.TextInputLayout> diff --git a/src/android/app/src/main/res/layout/layout_side_menu_checkbox_item.xml b/src/android/app/src/main/res/layout/layout_side_menu_checkbox_item.xml new file mode 100644 index 00000000..1855b7fa --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_side_menu_checkbox_item.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android"> + + <data> + + <variable + name="label" + type="java.lang.String" /> + </data> + + <LinearLayout + android:id="@+id/checkbox_item" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:minHeight="48dp" + android:orientation="horizontal" + android:padding="8dp"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center_vertical" + android:labelFor="@id/checkbox" + android:text="@{label}" + android:textSize="16sp" /> + + <com.google.android.material.checkbox.MaterialCheckBox + android:id="@+id/checkbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clickable="false" + android:minHeight="0dp" /> + </LinearLayout> +</layout> diff --git a/src/android/app/src/main/res/layout/layout_side_menu_emulation.xml b/src/android/app/src/main/res/layout/layout_side_menu_emulation.xml new file mode 100644 index 00000000..d0eff71d --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_side_menu_emulation.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <com.google.android.material.card.MaterialCardView + style="?attr/materialCardViewFilledStyle" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_gravity="start" + app:cardBackgroundColor="?colorSurface" + app:shapeAppearance="@style/ShapeAppearance.RightCorners"> + + <ScrollView + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:padding="8dp"> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:minWidth="200dp" + android:orientation="vertical"> + + <include + android:id="@+id/enable_motion_checkbox" + layout="@layout/layout_side_menu_checkbox_item" /> + + <include + android:id="@+id/lock_drawer_checkbox" + layout="@layout/layout_side_menu_checkbox_item" /> + + <include + android:id="@+id/replace_tv_with_pad_checkbox" + layout="@layout/layout_side_menu_checkbox_item" /> + + <include + android:id="@+id/show_pad_checkbox" + layout="@layout/layout_side_menu_checkbox_item" /> + + <include + android:id="@+id/edit_inputs_menu_item" + layout="@layout/layout_side_menu_text_item" /> + + <include + android:id="@+id/reset_input_overlay_menu_item" + layout="@layout/layout_side_menu_text_item" /> + + <include + android:id="@+id/show_input_overlay_checkbox" + layout="@layout/layout_side_menu_checkbox_item" /> + + <include + android:id="@+id/exit_menu_item" + layout="@layout/layout_side_menu_text_item" /> + </LinearLayout> + + </ScrollView> + + </com.google.android.material.card.MaterialCardView> +</layout> diff --git a/src/android/app/src/main/res/layout/layout_side_menu_text_item.xml b/src/android/app/src/main/res/layout/layout_side_menu_text_item.xml new file mode 100644 index 00000000..08759523 --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_side_menu_text_item.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android"> + + <data> + + <variable + name="label" + type="java.lang.String" /> + + </data> + + <TextView + android:id="@+id/text_item" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:gravity="center_vertical" + android:minHeight="48dp" + android:padding="8dp" + android:text="@{label}" + android:textSize="16sp" /> +</layout> \ No newline at end of file diff --git a/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..033bd784 --- /dev/null +++ b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@mipmap/ic_launcher_foreground" /> + <monochrome android:drawable="@mipmap/ic_launcher_foreground" /> +</adaptive-icon> \ No newline at end of file diff --git a/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..033bd784 --- /dev/null +++ b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@mipmap/ic_launcher_foreground" /> + <monochrome android:drawable="@mipmap/ic_launcher_foreground" /> +</adaptive-icon> \ No newline at end of file diff --git a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..60fa3208 Binary files /dev/null and b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..3269abd2 Binary files /dev/null and b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..f83c82b3 Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..36d87584 Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..74be2006 Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/src/android/app/src/main/res/values-land/dimens.xml b/src/android/app/src/main/res/values-land/dimens.xml new file mode 100644 index 00000000..65c04647 --- /dev/null +++ b/src/android/app/src/main/res/values-land/dimens.xml @@ -0,0 +1,3 @@ +<resources> + <dimen name="settings_fab_margin">48dp</dimen> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values-night/themes.xml b/src/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..e866a714 --- /dev/null +++ b/src/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,4 @@ +<resources> + + <style name="Base.Theme.Cemu" parent="Theme.Material3.DynamicColors.Dark" /> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values-w1240dp/dimens.xml b/src/android/app/src/main/res/values-w1240dp/dimens.xml new file mode 100644 index 00000000..63f5808e --- /dev/null +++ b/src/android/app/src/main/res/values-w1240dp/dimens.xml @@ -0,0 +1,3 @@ +<resources> + <dimen name="settings_fab_margin">200dp</dimen> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values-w1240dp/integers.xml b/src/android/app/src/main/res/values-w1240dp/integers.xml new file mode 100644 index 00000000..27a1b013 --- /dev/null +++ b/src/android/app/src/main/res/values-w1240dp/integers.xml @@ -0,0 +1,3 @@ +<resources> + <integer name="games_view_span_count">2</integer> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values-w600dp/dimens.xml b/src/android/app/src/main/res/values-w600dp/dimens.xml new file mode 100644 index 00000000..65c04647 --- /dev/null +++ b/src/android/app/src/main/res/values-w600dp/dimens.xml @@ -0,0 +1,3 @@ +<resources> + <dimen name="settings_fab_margin">48dp</dimen> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/colors.xml b/src/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..1013fe28 --- /dev/null +++ b/src/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> + <color name="red">#FFFF0000</color> + <color name="purple">#FFA020F0</color> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..0bb1f4f2 --- /dev/null +++ b/src/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ +<resources> + <dimen name="settings_fab_margin">16dp</dimen> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml new file mode 100644 index 00000000..53d9c37b --- /dev/null +++ b/src/android/app/src/main/res/values/integers.xml @@ -0,0 +1,3 @@ +<resources> + <integer name="games_view_span_count">1</integer> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/refs.xml b/src/android/app/src/main/res/values/refs.xml new file mode 100644 index 00000000..3a090684 --- /dev/null +++ b/src/android/app/src/main/res/values/refs.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources /> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..053d3be8 --- /dev/null +++ b/src/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ +<resources> + <string name="app_name" translatable="false">Cemu</string> + <string name="cemu_original_authors" translatable="false">Exzap, Petergov</string> + <string name="cemu_website" translatable="false">https://cemu.info</string> + <string name="cemu_online_guide" translatable="false">https://cemu.info/online-guide</string> +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/values/themes.xml b/src/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..835b00d4 --- /dev/null +++ b/src/android/app/src/main/res/values/themes.xml @@ -0,0 +1,21 @@ +<resources> + + <style name="Base.Theme.Cemu" parent="Theme.Material3.DynamicColors.Light" /> + + <style name="Theme.Cemu" parent="Base.Theme.Cemu"> + <item name="windowActionBar">false</item> + <item name="windowNoTitle">true</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + <item name="android:windowLightStatusBar">?attr/isLightTheme</item> + </style> + + <style name="ShapeAppearance.RightCorners" parent=""> + <item name="cornerFamily">rounded</item> + <item name="cornerSizeTopLeft">0dp</item> + <item name="cornerSizeTopRight">20dp</item> + <item name="cornerSizeBottomLeft">0dp</item> + <item name="cornerSizeBottomRight">20dp</item> + </style> + +</resources> \ No newline at end of file diff --git a/src/android/app/src/main/res/xml/backup_rules.xml b/src/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..fa0f996d --- /dev/null +++ b/src/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample backup rules file; uncomment and customize as necessary. + See https://developer.android.com/guide/topics/data/autobackup + for details. + Note: This file is ignored for devices older that API 31 + See https://developer.android.com/about/versions/12/backup-restore +--> +<full-backup-content> + <!-- + <include domain="sharedpref" path="."/> + <exclude domain="sharedpref" path="device.xml"/> +--> +</full-backup-content> \ No newline at end of file diff --git a/src/android/app/src/main/res/xml/data_extraction_rules.xml b/src/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/src/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample data extraction rules file; uncomment and customize as necessary. + See https://developer.android.com/about/versions/12/backup-restore#xml-changes + for details. +--> +<data-extraction-rules> + <cloud-backup> + <!-- TODO: Use <include> and <exclude> to control what is backed up. + <include .../> + <exclude .../> + --> + </cloud-backup> + <!-- + <device-transfer> + <include .../> + <exclude .../> + </device-transfer> + --> +</data-extraction-rules> \ No newline at end of file diff --git a/src/android/app/src/main/res/xml/input_overlay_default_configs.xml b/src/android/app/src/main/res/xml/input_overlay_default_configs.xml new file mode 100644 index 00000000..a7f56316 --- /dev/null +++ b/src/android/app/src/main/res/xml/input_overlay_default_configs.xml @@ -0,0 +1,164 @@ +<?xml version="1.0" encoding="utf-8"?> +<input-overlay-default-configs> + <input-overlay-config> + <name>BUTTON_PLUS</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>272</padding-horizontal> + <padding-vertical>24</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_MINUS</name> + <align-bottom>true</align-bottom> + <padding-horizontal>272</padding-horizontal> + <padding-vertical>24</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_HOME</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>208</padding-horizontal> + <padding-vertical>80</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_L</name> + <padding-horizontal>48</padding-horizontal> + <padding-vertical>8</padding-vertical> + <width>72</width> + <height>36</height> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_ZL</name> + <padding-horizontal>48</padding-horizontal> + <padding-vertical>64</padding-vertical> + <width>72</width> + <height>36</height> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_R</name> + <align-end>true</align-end> + <padding-horizontal>48</padding-horizontal> + <padding-vertical>8</padding-vertical> + <width>72</width> + <height>36</height> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_ZR</name> + <align-end>true</align-end> + <padding-horizontal>48</padding-horizontal> + <padding-vertical>64</padding-vertical> + <width>72</width> + <height>36</height> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_C</name> + <align-end>true</align-end> + <padding-horizontal>60</padding-horizontal> + <padding-vertical>8</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_Z</name> + <align-end>true</align-end> + <padding-horizontal>48</padding-horizontal> + <padding-vertical>64</padding-vertical> + <width>72</width> + <height>36</height> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_A</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>32</padding-horizontal> + <padding-vertical>56</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_Y</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>128</padding-horizontal> + <padding-vertical>56</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_X</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>80</padding-horizontal> + <padding-vertical>104</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_B</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>80</padding-horizontal> + <padding-vertical>8</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_ONE</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>128</padding-horizontal> + <padding-vertical>56</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_TWO</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>80</padding-horizontal> + <padding-vertical>104</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_R_STICK_CLICK</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>208</padding-horizontal> + <padding-vertical>80</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_L_STICK_CLICK</name> + <align-bottom>true</align-bottom> + <padding-horizontal>208</padding-horizontal> + <padding-vertical>80</padding-vertical> + <size>48</size> + </input-overlay-config> + <input-overlay-config> + <name>AXIS_LEFT</name> + <align-bottom>true</align-bottom> + <padding-horizontal>144</padding-horizontal> + <padding-vertical>120</padding-vertical> + <size>72</size> + </input-overlay-config> + <input-overlay-config> + <name>AXIS_RIGHT</name> + <align-end>true</align-end> + <align-bottom>true</align-bottom> + <padding-horizontal>144</padding-horizontal> + <padding-vertical>120</padding-vertical> + <size>72</size> + </input-overlay-config> + <input-overlay-config> + <name>DPAD</name> + <align-bottom>true</align-bottom> + <size>144</size> + <padding-horizontal>8</padding-horizontal> + <padding-vertical>8</padding-vertical> + </input-overlay-config> + <input-overlay-config> + <name>BUTTON_BLOW_MIC</name> + <align-bottom>true</align-bottom> + <align-end>true</align-end> + <padding-horizontal>8</padding-horizontal> + <padding-vertical>8</padding-vertical> + <size>40</size> + </input-overlay-config> +</input-overlay-default-configs> \ No newline at end of file diff --git a/src/android/app/src/test/java/info/cemu/cemu/tests/ArchitectureTests.kt b/src/android/app/src/test/java/info/cemu/cemu/tests/ArchitectureTests.kt new file mode 100644 index 00000000..7e2846db --- /dev/null +++ b/src/android/app/src/test/java/info/cemu/cemu/tests/ArchitectureTests.kt @@ -0,0 +1,88 @@ +package info.cemu.cemu.tests + +import com.tngtech.archunit.base.DescribedPredicate +import com.tngtech.archunit.base.DescribedPredicate.alwaysTrue +import com.tngtech.archunit.base.DescribedPredicate.not +import com.tngtech.archunit.base.DescribedPredicate.or +import com.tngtech.archunit.core.domain.JavaClass +import com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage +import com.tngtech.archunit.core.domain.JavaClasses +import com.tngtech.archunit.core.importer.ImportOption.DoNotIncludeTests +import com.tngtech.archunit.junit.AnalyzeClasses +import com.tngtech.archunit.junit.ArchTest +import com.tngtech.archunit.junit.ArchUnitRunner +import com.tngtech.archunit.junit.CacheMode +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition +import org.junit.runner.RunWith + +@RunWith(ArchUnitRunner::class) +@AnalyzeClasses( + packages = ["info.cemu.cemu"], + importOptions = [DoNotIncludeTests::class], + cacheMode = CacheMode.PER_CLASS +) +class ArchitectureTests { + private val isFromEntryPointCode = + object : DescribedPredicate<JavaClass>("is from entry point code") { + private val entryPointFiles = listOf( + "MainActivity.kt", + "CemuApplication.kt", + ) + + override fun test(input: JavaClass?): Boolean { + return entryPointFiles.any { input?.sourceCodeLocation?.sourceFileName == it } + } + } + + private val isFromGeneratedCode = + object : DescribedPredicate<JavaClass>("is from generated code") { + override fun test(input: JavaClass?): Boolean { + if (isFromEntryPointCode.test(input)) { + return false + } + + return input?.packageName == "info.cemu.cemu" + || input?.packageName?.contains("databinding") ?: false + } + } + + private val isFromTests = resideInAPackage("info.cemu.cemu.tests..") + + private val isFromNativeInterface = resideInAPackage("info.cemu.cemu.nativeinterface..") + + private fun JavaClasses.thatAreFromMainSources() = + that(not(or(isFromGeneratedCode, isFromTests))) + + @ArchTest + fun `feature packages should not depend on each other`(javaClasses: JavaClasses) { + SlicesRuleDefinition.slices().matching("info.cemu.cemu.(*)..") + .should().notDependOnEachOther() + .ignoreDependency( + alwaysTrue(), + or( + resideInAPackage("info.cemu.cemu.common.."), + resideInAPackage("info.cemu.cemu.nativeinterface.."), + resideInAPackage("info.cemu.cemu.databinding..") + ) + ) + .check(javaClasses.thatAreFromMainSources()) + } + + @ArchTest + fun `no packages should depend on entry point code`(javaClasses: JavaClasses) { + noClasses().that(not(isFromEntryPointCode)) + .should().dependOnClassesThat(isFromEntryPointCode) + .check(javaClasses.thatAreFromMainSources()) + } + + @ArchTest + fun `native interface should not depend on any other app packages`(javaClasses: JavaClasses) { + noClasses().that(isFromNativeInterface) + .should().dependOnClassesThat( + resideInAPackage("info.cemu.cemu..") + .and(not(isFromNativeInterface)) + ) + .check(javaClasses.thatAreFromMainSources()) + } +} diff --git a/src/android/build.gradle.kts b/src/android/build.gradle.kts new file mode 100644 index 00000000..04957b8d --- /dev/null +++ b/src/android/build.gradle.kts @@ -0,0 +1,9 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.aboutlibraries.android) apply false +} diff --git a/src/android/gradle.properties b/src/android/gradle.properties new file mode 100644 index 00000000..20e2a015 --- /dev/null +++ b/src/android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/src/android/gradle/libs.versions.toml b/src/android/gradle/libs.versions.toml new file mode 100644 index 00000000..bb6f1ce3 --- /dev/null +++ b/src/android/gradle/libs.versions.toml @@ -0,0 +1,50 @@ +[versions] +agp = "8.12.1" +aboutlibraries = "13.0.0-a01" +appcompat = "1.7.1" +archunit-junit4 = "1.4.1" +kotlin = "2.2.10" +androidx-core-ktx = "1.17.0" +androidx-activity-compose = "1.10.1" +androidx-compose-bom = "2025.08.00" +androidx-compose-material3 = "1.3.2" +google-android-material = "1.12.0" +androidx-navigation-compose = "2.9.3" +junit = "4.13.2" +androidx-junit = "1.3.0" +espresso-core = "3.7.0" +okhttp = "5.1.0" +kotlinx-serialization-json = "1.9.0" +kotlinx-gettext = "0.7.0" + +[libraries] +aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } +archunit-junit4 = { module = "com.tngtech.archunit:archunit-junit4", version.ref = "archunit-junit4" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" } +google-android-material = { module = "com.google.android.material:material", version.ref = "google-android-material" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version.ref = "okhttp" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-gettext = { module = "name.kropp.kotlinx-gettext:kotlinx-gettext", version.ref = "kotlinx-gettext" } + +[plugins] +aboutlibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlibraries" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinx-gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "kotlinx-gettext" } diff --git a/src/android/gradle/wrapper/gradle-wrapper.jar b/src/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/src/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/android/gradle/wrapper/gradle-wrapper.properties b/src/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..697a0b4d --- /dev/null +++ b/src/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jun 07 12:59:34 EEST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/android/gradlew b/src/android/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/src/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src/android/gradlew.bat b/src/android/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/src/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/android/settings.gradle.kts b/src/android/settings.gradle.kts new file mode 100644 index 00000000..8db477cf --- /dev/null +++ b/src/android/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Cemu" +include(":app") + \ No newline at end of file diff --git a/src/resource/logo_icon_128.png b/src/resource/logo_icon_128.png deleted file mode 100644 index 9291a627..00000000 Binary files a/src/resource/logo_icon_128.png and /dev/null differ diff --git a/src/resource/logo_icon_16.png b/src/resource/logo_icon_16.png deleted file mode 100644 index 838f7955..00000000 Binary files a/src/resource/logo_icon_16.png and /dev/null differ diff --git a/src/resource/logo_icon_new.png b/src/resource/logo_icon_new.png deleted file mode 100644 index fc15765d..00000000 Binary files a/src/resource/logo_icon_new.png and /dev/null differ