From d59fcf01bf9a7b3779e537adbbf5be483db8d381 Mon Sep 17 00:00:00 2001 From: crueter Date: Wed, 4 Feb 2026 04:16:39 +0100 Subject: [PATCH 1/3] [frontend] Generate web token at runtime (#3462) Rather than having that hardcoded one like before. Also adds infrastructure which should make it easier to setup defaults at runtime (e.g. GPU stuff?) Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3462 Reviewed-by: DraVee Reviewed-by: Lizzie --- CMakeLists.txt | 4 +-- cpmfile.json | 6 ---- externals/CMakeLists.txt | 3 ++ externals/cpmfile.json | 6 ++++ .../app/src/main/jni/native_config.cpp | 6 ++-- src/common/settings.h | 2 +- src/frontend_common/CMakeLists.txt | 9 ++++-- src/frontend_common/settings_generator.cpp | 31 +++++++++++++++++++ src/frontend_common/settings_generator.h | 14 +++++++++ src/qt_common/CMakeLists.txt | 5 +-- src/yuzu/main_window.cpp | 2 ++ 11 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 src/frontend_common/settings_generator.cpp create mode 100644 src/frontend_common/settings_generator.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 83493c0386..175ad7c3f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -589,6 +589,7 @@ if (NOT YUZU_STATIC_ROOM) find_package(sirit) find_package(gamemode) find_package(mcl) + find_package(frozen) if (ARCHITECTURE_riscv64) find_package(biscuit) @@ -700,9 +701,6 @@ if (ENABLE_QT) # QuaZip AddJsonPackage(quazip) - - # frozen - AddJsonPackage(frozen) endif() if (NOT YUZU_STATIC_ROOM AND NOT (YUZU_USE_BUNDLED_FFMPEG OR YUZU_USE_EXTERNAL_FFMPEG)) diff --git a/cpmfile.json b/cpmfile.json index 7a904c88ca..80086797af 100644 --- a/cpmfile.json +++ b/cpmfile.json @@ -114,11 +114,5 @@ "QUAZIP_INSTALL OFF", "QUAZIP_ENABLE_QTEXTCODEC OFF" ] - }, - "frozen": { - "package": "frozen", - "repo": "serge-sans-paille/frozen", - "sha": "61dce5ae18", - "hash": "b8dfe741c82bc178dfc9749d4ab5a130cee718d9ee7b71d9b547cf5f7f23027ed0152ad250012a8546399fcc1e12187efc68d89d6731256c4d2df7d04eef8d5c" } } diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index 81e2b524e8..b57c52d151 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -92,6 +92,9 @@ if (NOT VulkanUtilityLibraries_ADDED) find_package(VulkanHeaders 1.3.274 REQUIRED) endif() +# frozen +AddJsonPackage(frozen) + # DiscordRPC if (USE_DISCORD_PRESENCE) if (ARCHITECTURE_arm64) diff --git a/externals/cpmfile.json b/externals/cpmfile.json index ac7dbbe9b7..2f900e4ebe 100644 --- a/externals/cpmfile.json +++ b/externals/cpmfile.json @@ -287,5 +287,11 @@ "hash": "8147370f964fd82c315d6bb89adeda30186098427bf3efaa641d36282d42a263f31e96e4586bfd7ae0410ff015379c19aa4512ba160630444d3d8553afd1ec14", "git_version": "1.4.342", "tag": "v%VERSION%" + }, + "frozen": { + "package": "frozen", + "repo": "serge-sans-paille/frozen", + "sha": "61dce5ae18", + "hash": "b8dfe741c82bc178dfc9749d4ab5a130cee718d9ee7b71d9b547cf5f7f23027ed0152ad250012a8546399fcc1e12187efc68d89d6731256c4d2df7d04eef8d5c" } } diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 800f3e4569..81c0afc7ef 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -1,18 +1,19 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include #include -#include #include "android_config.h" #include "android_settings.h" #include "common/android/android_common.h" #include "common/android/id_cache.h" +#include "common/fs/path_util.h" #include "common/logging/log.h" #include "common/settings.h" #include "frontend_common/config.h" +#include "frontend_common/settings_generator.h" #include "native.h" std::unique_ptr global_config; @@ -37,6 +38,7 @@ extern "C" { void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_initializeGlobalConfig(JNIEnv* env, jobject obj) { global_config = std::make_unique(); + FrontendCommon::GenerateSettings(); } void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, jobject obj) { diff --git a/src/common/settings.h b/src/common/settings.h index 85f3cb21cd..f29d041c17 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -786,7 +786,7 @@ struct Values { Category::WebService}; Setting eden_username{linkage, "Eden", "eden_username", Category::WebService}; - Setting eden_token{linkage, "njausoolxygtpvraofqunuufhmupriifnpfggjxefntlyglr", + Setting eden_token{linkage, "", "eden_token", Category::WebService}; // Add-Ons diff --git a/src/frontend_common/CMakeLists.txt b/src/frontend_common/CMakeLists.txt index b8a282b234..2f75fede57 100644 --- a/src/frontend_common/CMakeLists.txt +++ b/src/frontend_common/CMakeLists.txt @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +# SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -12,7 +12,8 @@ add_library(frontend_common STATIC firmware_manager.cpp data_manager.h data_manager.cpp play_time_manager.cpp - play_time_manager.h) + play_time_manager.h + settings_generator.h settings_generator.cpp) if (ENABLE_UPDATE_CHECKER) target_link_libraries(frontend_common PRIVATE httplib::httplib) @@ -29,4 +30,6 @@ if (ENABLE_UPDATE_CHECKER) endif() create_target_directory_groups(frontend_common) -target_link_libraries(frontend_common PUBLIC core SimpleIni::SimpleIni PRIVATE common Boost::headers) +target_link_libraries(frontend_common + PUBLIC core SimpleIni::SimpleIni frozen::frozen-headers + PRIVATE common Boost::headers) diff --git a/src/frontend_common/settings_generator.cpp b/src/frontend_common/settings_generator.cpp new file mode 100644 index 0000000000..0f09878b30 --- /dev/null +++ b/src/frontend_common/settings_generator.cpp @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include "common/settings.h" +#include "settings_generator.h" + +namespace FrontendCommon { + +void GenerateSettings() { + static std::random_device rd; + + // Web Token // + auto &token_setting = Settings::values.eden_token; + if (token_setting.GetValue().empty()) { + static constexpr const size_t token_length = 48; + static constexpr const frozen::string token_set = "abcdefghijklmnopqrstuvwxyz"; + static std::uniform_int_distribution token_dist(0, token_set.size() - 1); + std::string result; + + for (size_t i = 0; i < token_length; ++i) { + size_t idx = token_dist(rd); + result += token_set[idx]; + } + + token_setting.SetValue(result); + } +} + +} diff --git a/src/frontend_common/settings_generator.h b/src/frontend_common/settings_generator.h new file mode 100644 index 0000000000..9dc3d60f1b --- /dev/null +++ b/src/frontend_common/settings_generator.h @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +namespace FrontendCommon { + +/** + * @brief GenerateSettings Generate platform-specific or randomized settings. + * Run this function at initialization time for your frontend. + */ +void GenerateSettings(); + +} diff --git a/src/qt_common/CMakeLists.txt b/src/qt_common/CMakeLists.txt index 2dc342cf9d..073301b313 100644 --- a/src/qt_common/CMakeLists.txt +++ b/src/qt_common/CMakeLists.txt @@ -78,12 +78,9 @@ target_compile_definitions(qt_common PUBLIC QT_NO_URL_CAST_FROM_STRING ) -# pass targets -find_package(frozen) - target_link_libraries(qt_common PRIVATE core Qt6::Core Qt6::Concurrent SimpleIni::SimpleIni QuaZip::QuaZip) target_link_libraries(qt_common PUBLIC frozen::frozen-headers) -target_link_libraries(qt_common PRIVATE gamemode::headers) +target_link_libraries(qt_common PRIVATE gamemode::headers frontend_common) if (NOT APPLE AND ENABLE_OPENGL) target_compile_definitions(qt_common PUBLIC HAS_OPENGL) diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index d50dcad994..5fee35dc7f 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3,6 +3,7 @@ // Qt on macOS doesn't define VMA shit #include +#include "frontend_common/settings_generator.h" #include "qt_common/qt_string_lookup.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) #undef VMA_IMPLEMENTATION @@ -432,6 +433,7 @@ MainWindow::MainWindow(bool has_broken_vulkan) Common::Log::Start(); LoadTranslation(); + FrontendCommon::GenerateSettings(); setAcceptDrops(true); ui->setupUi(this); From 48ba1f3f241123ca4950fed5965fe78bb804849c Mon Sep 17 00:00:00 2001 From: wildcard Date: Wed, 4 Feb 2026 19:15:10 +0100 Subject: [PATCH 2/3] [vk, cmake] Bump minimum VulkanTools to 1.4.317 and add UnifiedImageLayouts (#3318) It seems too easy, the specification does not state anything more to be done for it towork. Requires performance testing on android. Co-authored-by: DraVee Co-authored-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3318 Reviewed-by: DraVee Reviewed-by: CamilleLaVey Co-authored-by: wildcard Co-committed-by: wildcard --- CMakeModules/CPMUtil.cmake | 315 +++++++++++------- docs/CPMUtil/AddDependentPackage.md | 41 +++ docs/CPMUtil/README.md | 4 + externals/CMakeLists.txt | 8 +- externals/cpmfile.json | 2 +- .../vulkan_common/vulkan_device.cpp | 4 + src/video_core/vulkan_common/vulkan_device.h | 4 +- tools/cpm/common.sh | 2 +- 8 files changed, 246 insertions(+), 134 deletions(-) create mode 100644 docs/CPMUtil/AddDependentPackage.md diff --git a/CMakeModules/CPMUtil.cmake b/CMakeModules/CPMUtil.cmake index 834e07e665..4c9b645d16 100644 --- a/CMakeModules/CPMUtil.cmake +++ b/CMakeModules/CPMUtil.cmake @@ -41,6 +41,11 @@ function(cpm_utils_message level name message) message(${level} "[CPMUtil] ${name}: ${message}") endfunction() +# propagate a variable to parent scope +macro(Propagate var) + set(${var} ${${var}} PARENT_SCOPE) +endmacro() + function(array_to_list array length out) math(EXPR range "${length} - 1") @@ -72,6 +77,159 @@ function(get_json_element object out member default) set("${out}" "${outvar}" PARENT_SCOPE) endfunction() +# Determine whether or not a package has a viable system candidate. +function(SystemPackageViable JSON_NAME) + string(JSON object GET "${CPMFILE_CONTENT}" "${JSON_NAME}") + + parse_object(${object}) + + string(REPLACE " " ";" find_args "${find_args}") + find_package(${package} ${version} ${find_args} QUIET NO_POLICY_SCOPE) + + set(${pkg}_VIABLE ${${package}_FOUND} PARENT_SCOPE) + set(${pkg}_PACKAGE ${package} PARENT_SCOPE) +endfunction() + +# Add several packages such that if one is bundled, +# all the rest must also be bundled. +function(AddDependentPackages) + set(_some_system OFF) + set(_some_bundled OFF) + + foreach(pkg ${ARGN}) + SystemPackageViable(${pkg}) + + if (${pkg}_VIABLE) + set(_some_system ON) + list(APPEND _system_pkgs ${${pkg}_PACKAGE}) + else() + set(_some_bundled ON) + list(APPEND _bundled_pkgs ${${pkg}_PACKAGE}) + endif() + endforeach() + + if (_some_system AND _some_bundled) + foreach(pkg ${ARGN}) + list(APPEND package_names ${${pkg}_PACKAGE}) + endforeach() + + string(REPLACE ";" ", " package_names "${package_names}") + string(REPLACE ";" ", " bundled_names "${_bundled_pkgs}") + foreach(sys ${_system_pkgs}) + list(APPEND system_names ${sys}_FORCE_BUNDLED) + endforeach() + + string(REPLACE ";" ", " system_names "${system_names}") + + message(FATAL_ERROR "Partial dependency installation detected " + "for the following packages:\n${package_names}\n" + "You can solve this in one of two ways:\n" + "1. Install the following packages to your system if available:" + "\n\t${bundled_names}\n" + "2. Set the following variables to ON:" + "\n\t${system_names}\n" + "This may also be caused by a version mismatch, " + "such as one package being newer than the other.") + endif() + + foreach(pkg ${ARGN}) + AddJsonPackage(${pkg}) + endforeach() +endfunction() + +# json util +macro(parse_object object) + get_json_element("${object}" package package ${JSON_NAME}) + get_json_element("${object}" repo repo "") + get_json_element("${object}" ci ci OFF) + get_json_element("${object}" version version "") + + if(ci) + get_json_element("${object}" name name "${JSON_NAME}") + get_json_element("${object}" extension extension "tar.zst") + get_json_element("${object}" min_version min_version "") + get_json_element("${object}" raw_disabled disabled_platforms "") + + if(raw_disabled) + array_to_list("${raw_disabled}" + ${raw_disabled_LENGTH} disabled_platforms) + else() + set(disabled_platforms "") + endif() + else() + get_json_element("${object}" hash hash "") + get_json_element("${object}" hash_suffix hash_suffix "") + get_json_element("${object}" sha sha "") + get_json_element("${object}" url url "") + get_json_element("${object}" key key "") + get_json_element("${object}" tag tag "") + get_json_element("${object}" artifact artifact "") + get_json_element("${object}" git_version git_version "") + get_json_element("${object}" git_host git_host "") + get_json_element("${object}" source_subdir source_subdir "") + get_json_element("${object}" bundled bundled "unset") + get_json_element("${object}" find_args find_args "") + get_json_element("${object}" raw_patches patches "") + + # okay here comes the fun part: REPLACEMENTS! + # first: tag gets %VERSION% replaced if applicable, + # with either git_version (preferred) or version + # second: artifact gets %VERSION% and %TAG% replaced + # accordingly (same rules for VERSION) + + if(git_version) + set(version_replace ${git_version}) + else() + set(version_replace ${version}) + endif() + + # TODO(crueter): fmt module for cmake + if(tag) + string(REPLACE "%VERSION%" "${version_replace}" tag ${tag}) + endif() + + if(artifact) + string(REPLACE "%VERSION%" "${version_replace}" + artifact ${artifact}) + string(REPLACE "%TAG%" "${tag}" artifact ${artifact}) + endif() + + # format patchdir + if(raw_patches) + math(EXPR range "${raw_patches_LENGTH} - 1") + + foreach(IDX RANGE ${range}) + string(JSON _patch GET "${raw_patches}" "${IDX}") + + set(full_patch + "${PROJECT_SOURCE_DIR}/.patch/${JSON_NAME}/${_patch}") + if(NOT EXISTS ${full_patch}) + cpm_utils_message(FATAL_ERROR ${JSON_NAME} + "specifies patch ${full_patch} which does not exist") + endif() + + list(APPEND patches "${full_patch}") + endforeach() + endif() + # end format patchdir + + # options + get_json_element("${object}" raw_options options "") + + if(raw_options) + array_to_list("${raw_options}" ${raw_options_LENGTH} options) + endif() + + set(options ${options} ${JSON_OPTIONS}) + # end options + + # system/bundled + if(bundled STREQUAL "unset" AND DEFINED JSON_BUNDLED_PACKAGE) + set(bundled ${JSON_BUNDLED_PACKAGE}) + endif() + endif() +endmacro() + # The preferred usage function(AddJsonPackage) set(oneValueArgs @@ -80,7 +238,8 @@ function(AddJsonPackage) # these are overrides that can be generated at runtime, # so can be defined separately from the json DOWNLOAD_ONLY - BUNDLED_PACKAGE) + BUNDLED_PACKAGE + FORCE_BUNDLED_PACKAGE) set(multiValueArgs OPTIONS) @@ -111,24 +270,9 @@ function(AddJsonPackage) cpm_utils_message(FATAL_ERROR ${JSON_NAME} "Not found in cpmfile") endif() - get_json_element("${object}" package package ${JSON_NAME}) - get_json_element("${object}" repo repo "") - get_json_element("${object}" ci ci OFF) - get_json_element("${object}" version version "") + parse_object(${object}) if(ci) - get_json_element("${object}" name name "${JSON_NAME}") - get_json_element("${object}" extension extension "tar.zst") - get_json_element("${object}" min_version min_version "") - get_json_element("${object}" raw_disabled disabled_platforms "") - - if(raw_disabled) - array_to_list("${raw_disabled}" - ${raw_disabled_LENGTH} disabled_platforms) - else() - set(disabled_platforms "") - endif() - AddCIPackage( VERSION ${version} NAME ${name} @@ -138,116 +282,38 @@ function(AddJsonPackage) MIN_VERSION ${min_version} DISABLED_PLATFORMS ${disabled_platforms}) - # pass stuff to parent scope - set(${package}_ADDED "${${package}_ADDED}" - PARENT_SCOPE) - set(${package}_SOURCE_DIR "${${package}_SOURCE_DIR}" - PARENT_SCOPE) - set(${package}_BINARY_DIR "${${package}_BINARY_DIR}" - PARENT_SCOPE) - - return() - endif() - - get_json_element("${object}" hash hash "") - get_json_element("${object}" hash_suffix hash_suffix "") - get_json_element("${object}" sha sha "") - get_json_element("${object}" url url "") - get_json_element("${object}" key key "") - get_json_element("${object}" tag tag "") - get_json_element("${object}" artifact artifact "") - get_json_element("${object}" git_version git_version "") - get_json_element("${object}" git_host git_host "") - get_json_element("${object}" source_subdir source_subdir "") - get_json_element("${object}" bundled bundled "unset") - get_json_element("${object}" find_args find_args "") - get_json_element("${object}" raw_patches patches "") - - # okay here comes the fun part: REPLACEMENTS! - # first: tag gets %VERSION% replaced if applicable, - # with either git_version (preferred) or version - # second: artifact gets %VERSION% and %TAG% replaced - # accordingly (same rules for VERSION) - - if(git_version) - set(version_replace ${git_version}) else() - set(version_replace ${version}) - endif() - - # TODO(crueter): fmt module for cmake - if(tag) - string(REPLACE "%VERSION%" "${version_replace}" tag ${tag}) - endif() - - if(artifact) - string(REPLACE "%VERSION%" "${version_replace}" artifact ${artifact}) - string(REPLACE "%TAG%" "${tag}" artifact ${artifact}) - endif() - - # format patchdir - if(raw_patches) - math(EXPR range "${raw_patches_LENGTH} - 1") - - foreach(IDX RANGE ${range}) - string(JSON _patch GET "${raw_patches}" "${IDX}") - - set(full_patch - "${PROJECT_SOURCE_DIR}/.patch/${JSON_NAME}/${_patch}") - if(NOT EXISTS ${full_patch}) - cpm_utils_message(FATAL_ERROR ${JSON_NAME} - "specifies patch ${full_patch} which does not exist") - endif() - - list(APPEND patches "${full_patch}") - endforeach() - endif() - # end format patchdir - - # options - get_json_element("${object}" raw_options options "") - - if(raw_options) - array_to_list("${raw_options}" ${raw_options_LENGTH} options) - endif() - - set(options ${options} ${JSON_OPTIONS}) - # end options + if (NOT DEFINED JSON_FORCE_BUNDLED_PACKAGE) + set(JSON_FORCE_BUNDLED_PACKAGE OFF) + endif() - # system/bundled - if(bundled STREQUAL "unset" AND DEFINED JSON_BUNDLED_PACKAGE) - set(bundled ${JSON_BUNDLED_PACKAGE}) + AddPackage( + NAME "${package}" + VERSION "${version}" + URL "${url}" + HASH "${hash}" + HASH_SUFFIX "${hash_suffix}" + SHA "${sha}" + REPO "${repo}" + KEY "${key}" + PATCHES "${patches}" + OPTIONS "${options}" + FIND_PACKAGE_ARGUMENTS "${find_args}" + BUNDLED_PACKAGE "${bundled}" + FORCE_BUNDLED_PACKAGE "${JSON_FORCE_BUNDLED_PACKAGE}" + SOURCE_SUBDIR "${source_subdir}" + + GIT_VERSION ${git_version} + GIT_HOST ${git_host} + + ARTIFACT ${artifact} + TAG ${tag}) endif() - AddPackage( - NAME "${package}" - VERSION "${version}" - URL "${url}" - HASH "${hash}" - HASH_SUFFIX "${hash_suffix}" - SHA "${sha}" - REPO "${repo}" - KEY "${key}" - PATCHES "${patches}" - OPTIONS "${options}" - FIND_PACKAGE_ARGUMENTS "${find_args}" - BUNDLED_PACKAGE "${bundled}" - SOURCE_SUBDIR "${source_subdir}" - - GIT_VERSION ${git_version} - GIT_HOST ${git_host} - - ARTIFACT ${artifact} - TAG ${tag}) - # pass stuff to parent scope - set(${package}_ADDED "${${package}_ADDED}" - PARENT_SCOPE) - set(${package}_SOURCE_DIR "${${package}_SOURCE_DIR}" - PARENT_SCOPE) - set(${package}_BINARY_DIR "${${package}_BINARY_DIR}" - PARENT_SCOPE) - + Propagate(${package}_ADDED) + Propagate(${package}_SOURCE_DIR) + Propagate(${package}_BINARY_DIR) endfunction() function(AddPackage) @@ -343,7 +409,7 @@ function(AddPackage) if(DEFINED PKG_ARGS_ARTIFACT) set(pkg_url - ${pkg_git_url}/releases/download/${PKG_ARGS_TAG}/${PKG_ARGS_ARTIFACT}) + "${pkg_git_url}/releases/download/${PKG_ARGS_TAG}/${PKG_ARGS_ARTIFACT}") else() set(pkg_url ${pkg_git_url}/archive/refs/tags/${PKG_ARGS_TAG}.tar.gz) @@ -625,7 +691,8 @@ function(AddCIPackage) endif() if (DEFINED pkgname AND NOT "${pkgname}" IN_LIST DISABLED_PLATFORMS) - set(ARTIFACT "${ARTIFACT_NAME}-${pkgname}-${ARTIFACT_VERSION}.${ARTIFACT_EXT}") + set(ARTIFACT + "${ARTIFACT_NAME}-${pkgname}-${ARTIFACT_VERSION}.${ARTIFACT_EXT}") AddPackage( NAME ${ARTIFACT_PACKAGE} diff --git a/docs/CPMUtil/AddDependentPackage.md b/docs/CPMUtil/AddDependentPackage.md new file mode 100644 index 0000000000..bb6651e4b8 --- /dev/null +++ b/docs/CPMUtil/AddDependentPackage.md @@ -0,0 +1,41 @@ +# AddDependentPackage + +Use `AddDependentPackage` when you have multiple packages that are required to all be from the system, OR bundled. This is useful in cases where e.g. versions must absolutely match. + +## Versioning + +Versioning must be handled by the package itself. + +## Examples + +### Vulkan + +`cpmfile.json` + +```json +{ + "vulkan-headers": { + "repo": "KhronosGroup/Vulkan-Headers", + "package": "VulkanHeaders", + "version": "1.4.317", + "hash": "26e0ad8fa34ab65a91ca62ddc54cc4410d209a94f64f2817dcdb8061dc621539a4262eab6387e9b9aa421db3dbf2cf8e2a4b041b696d0d03746bae1f25191272", + "git_version": "1.4.342", + "tag": "v%VERSION%" + }, + "vulkan-utility-libraries": { + "repo": "KhronosGroup/Vulkan-Utility-Libraries", + "package": "VulkanUtilityLibraries", + "hash": "8147370f964fd82c315d6bb89adeda30186098427bf3efaa641d36282d42a263f31e96e4586bfd7ae0410ff015379c19aa4512ba160630444d3d8553afd1ec14", + "git_version": "1.4.342", + "tag": "v%VERSION%" + } +} +``` + +`CMakeLists.txt`: + +```cmake +AddDependentPackages(vulkan-headers vulkan-utility-libraries) +``` + +If Vulkan Headers are installed, but NOT Vulkan Utility Libraries, then CPMUtil will throw an error. diff --git a/docs/CPMUtil/README.md b/docs/CPMUtil/README.md index ff19cb4f76..dbc5f0922a 100644 --- a/docs/CPMUtil/README.md +++ b/docs/CPMUtil/README.md @@ -31,6 +31,10 @@ The core of CPMUtil is the [`AddPackage`](./AddPackage.md) function. [`AddPackag [`AddJsonPackage`](./AddJsonPackage.md) is the recommended method of usage for CPMUtil. +## AddDependentPackage + +[`AddDependentPackage`](./AddDependentPackage.md) allows you to add multiple packages such that all of them must be from the system OR bundled. + ## AddQt [`AddQt`](./AddQt.md) adds a specific version of Qt to your project. diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index b57c52d151..980d8f668d 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -84,13 +84,7 @@ endif() AddJsonPackage(mcl) # Vulkan stuff -AddJsonPackage(vulkan-headers) -AddJsonPackage(vulkan-utility-libraries) - -# small hack -if (NOT VulkanUtilityLibraries_ADDED) - find_package(VulkanHeaders 1.3.274 REQUIRED) -endif() +AddDependentPackages(vulkan-headers vulkan-utility-libraries) # frozen AddJsonPackage(frozen) diff --git a/externals/cpmfile.json b/externals/cpmfile.json index 2f900e4ebe..7c04e389e2 100644 --- a/externals/cpmfile.json +++ b/externals/cpmfile.json @@ -276,7 +276,7 @@ "vulkan-headers": { "repo": "KhronosGroup/Vulkan-Headers", "package": "VulkanHeaders", - "version": "1.3.274", + "version": "1.4.317", "hash": "26e0ad8fa34ab65a91ca62ddc54cc4410d209a94f64f2817dcdb8061dc621539a4262eab6387e9b9aa421db3dbf2cf8e2a4b041b696d0d03746bae1f25191272", "git_version": "1.4.342", "tag": "v%VERSION%" diff --git a/src/video_core/vulkan_common/vulkan_device.cpp b/src/video_core/vulkan_common/vulkan_device.cpp index 0b3aea6d2e..ab4e5b1b65 100644 --- a/src/video_core/vulkan_common/vulkan_device.cpp +++ b/src/video_core/vulkan_common/vulkan_device.cpp @@ -1230,6 +1230,10 @@ void Device::RemoveUnsuitableExtensions() { } RemoveExtensionFeatureIfUnsuitable(extensions.custom_border_color, features.custom_border_color, VK_EXT_CUSTOM_BORDER_COLOR_EXTENSION_NAME); + // VK_KHR_unified_image_layouts + extensions.unified_image_layouts = features.unified_image_layouts.unifiedImageLayouts; + RemoveExtensionFeatureIfUnsuitable(extensions.unified_image_layouts, features.unified_image_layouts, + VK_KHR_UNIFIED_IMAGE_LAYOUTS_EXTENSION_NAME); // VK_EXT_depth_bias_control extensions.depth_bias_control = diff --git a/src/video_core/vulkan_common/vulkan_device.h b/src/video_core/vulkan_common/vulkan_device.h index 744b5f827e..73a232ddee 100644 --- a/src/video_core/vulkan_common/vulkan_device.h +++ b/src/video_core/vulkan_common/vulkan_device.h @@ -70,7 +70,9 @@ VK_DEFINE_HANDLE(VmaAllocator) FEATURE(KHR, PipelineExecutableProperties, PIPELINE_EXECUTABLE_PROPERTIES, \ pipeline_executable_properties) \ FEATURE(KHR, WorkgroupMemoryExplicitLayout, WORKGROUP_MEMORY_EXPLICIT_LAYOUT, \ - workgroup_memory_explicit_layout) + workgroup_memory_explicit_layout) \ + FEATURE(KHR, UnifiedImageLayouts, UNIFIED_IMAGE_LAYOUTS, unified_image_layouts) + // Define miscellaneous extensions which may be used by the implementation here. #define FOR_EACH_VK_EXTENSION(EXTENSION) \ diff --git a/tools/cpm/common.sh b/tools/cpm/common.sh index 2dc3a477d4..97a2fed003 100755 --- a/tools/cpm/common.sh +++ b/tools/cpm/common.sh @@ -23,7 +23,7 @@ MAXDEPTH=3 # For your project you'll want to change this to define what dirs you have cpmfiles in # Remember to account for the MAXDEPTH variable! # Adding ./ before each will help to remove duplicates -CPMFILES=$(find . src -maxdepth "$MAXDEPTH" -name cpmfile.json | sort | uniq) +CPMFILES=$(find . -maxdepth "$MAXDEPTH" -name cpmfile.json | sort | uniq) # shellcheck disable=SC2016 PACKAGES=$(echo "$CPMFILES" | xargs jq -s 'reduce .[] as $item ({}; . * $item)') From c2f4449b1cf99abfc261576418db03e7b104436e Mon Sep 17 00:00:00 2001 From: nekle Date: Thu, 5 Feb 2026 02:17:08 +0100 Subject: [PATCH 3/3] [android] Add profile management (#3461) There could be an issue with save files being wiped if updating from an older version, this is due to profiles being hard set on android previously but am not sure, needs testing Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3461 Reviewed-by: Lizzie Reviewed-by: DraVee Co-authored-by: nekle Co-committed-by: nekle --- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 19 + .../adapters/FirmwareAvatarAdapter.kt | 55 +++ .../yuzu/yuzu_emu/adapters/ProfileAdapter.kt | 112 +++++ .../fragments/EditUserDialogFragment.kt | 459 ++++++++++++++++++ .../fragments/HomeSettingsFragment.kt | 11 + .../fragments/ProfileManagerFragment.kt | 190 ++++++++ .../org/yuzu/yuzu_emu/model/UserProfile.kt | 21 + .../yuzu_emu/utils/DirectoryInitialization.kt | 3 +- src/android/app/src/main/jni/native.cpp | 386 +++++++++++++++ .../main/res/drawable/ic_account_circle.xml | 9 + .../layout/dialog_firmware_avatar_picker.xml | 39 ++ .../res/layout/fragment_edit_user_dialog.xml | 226 +++++++++ .../res/layout/fragment_profile_manager.xml | 49 ++ .../main/res/layout/item_firmware_avatar.xml | 21 + .../src/main/res/layout/list_item_profile.xml | 125 +++++ .../main/res/navigation/home_navigation.xml | 15 + .../app/src/main/res/values/strings.xml | 29 ++ 17 files changed, 1768 insertions(+), 1 deletion(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FirmwareAvatarAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ProfileAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EditUserDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/UserProfile.kt create mode 100644 src/android/app/src/main/res/drawable/ic_account_circle.xml create mode 100644 src/android/app/src/main/res/layout/dialog_firmware_avatar_picker.xml create mode 100644 src/android/app/src/main/res/layout/fragment_edit_user_dialog.xml create mode 100644 src/android/app/src/main/res/layout/fragment_profile_manager.xml create mode 100644 src/android/app/src/main/res/layout/item_firmware_avatar.xml create mode 100644 src/android/app/src/main/res/layout/list_item_profile.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 4082454e7b..1f3d9a22a2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -614,4 +614,23 @@ object NativeLibrary { * Updates the device power state to global variables */ external fun updatePowerState(percentage: Int, isCharging: Boolean, hasBattery: Boolean) + + /** + * Profile manager native calls + */ + external fun getAllUsers(): Array? + external fun getUserUsername(uuid: String): String? + external fun getUserCount(): Long + external fun canCreateUser(): Boolean + external fun createUser(uuid: String, username: String): Boolean + external fun updateUserUsername(uuid: String, username: String): Boolean + external fun removeUser(uuid: String): Boolean + external fun getCurrentUser(): String? + external fun setCurrentUser(uuid: String): Boolean + external fun getUserImagePath(uuid: String): String? + external fun saveUserImage(uuid: String, imagePath: String): Boolean + external fun reloadProfiles() + external fun getFirmwareAvatarCount(): Int + external fun getFirmwareAvatarImage(index: Int): ByteArray? + external fun getDefaultAccountBackupJpeg(): ByteArray } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FirmwareAvatarAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FirmwareAvatarAdapter.kt new file mode 100644 index 0000000000..68bc6a9ad4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FirmwareAvatarAdapter.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.graphics.Bitmap +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.ItemFirmwareAvatarBinding + +class FirmwareAvatarAdapter( + private val avatars: List, + private val onAvatarSelected: (Bitmap) -> Unit +) : RecyclerView.Adapter() { + + private var selectedPosition = -1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AvatarViewHolder { + val binding = ItemFirmwareAvatarBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return AvatarViewHolder(binding) + } + + override fun onBindViewHolder(holder: AvatarViewHolder, position: Int) { + holder.bind(avatars[position], position == selectedPosition) + } + + override fun getItemCount(): Int = avatars.size + + inner class AvatarViewHolder( + private val binding: ItemFirmwareAvatarBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(avatar: Bitmap, isSelected: Boolean) { + binding.imageAvatar.setImageBitmap(avatar) + binding.root.isChecked = isSelected + + binding.root.setOnClickListener { + val previousSelected = selectedPosition + selectedPosition = bindingAdapterPosition + + if (previousSelected != -1) { + notifyItemChanged(previousSelected) + } + notifyItemChanged(selectedPosition) + + onAvatarSelected(avatar) + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ProfileAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ProfileAdapter.kt new file mode 100644 index 0000000000..994256b7d1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ProfileAdapter.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.graphics.BitmapFactory +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ListItemProfileBinding +import org.yuzu.yuzu_emu.model.UserProfile +import java.io.File +import org.yuzu.yuzu_emu.NativeLibrary + +class ProfileAdapter( + private val onProfileClick: (UserProfile) -> Unit, + private val onEditClick: (UserProfile) -> Unit, + private val onDeleteClick: (UserProfile) -> Unit +) : RecyclerView.Adapter() { + + private var currentUserUUID: String = "" + + private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UserProfile, newItem: UserProfile): Boolean { + return oldItem.uuid == newItem.uuid + } + + override fun areContentsTheSame(oldItem: UserProfile, newItem: UserProfile): Boolean { + return oldItem == newItem + } + }) + + fun submitList(list: List) { + differ.submitList(list) + } + + fun setCurrentUser(uuid: String) { + currentUserUUID = uuid + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder { + val binding = ListItemProfileBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ProfileViewHolder(binding) + } + + override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) { + holder.bind(differ.currentList[position]) + } + + override fun getItemCount(): Int = differ.currentList.size + + inner class ProfileViewHolder(private val binding: ListItemProfileBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(profile: UserProfile) { + binding.textUsername.text = profile.username + binding.textUuid.text = formatUUID(profile.uuid) + + val imageFile = File(profile.imagePath) + if (imageFile.exists()) { + val bitmap = BitmapFactory.decodeFile(profile.imagePath) + binding.imageAvatar.setImageBitmap(bitmap) + } else { + val jpegData = NativeLibrary.getDefaultAccountBackupJpeg() + val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size) + binding.imageAvatar.setImageBitmap(bitmap) + } + + if (profile.uuid == currentUserUUID) { + binding.checkContainer.visibility = View.VISIBLE + } else { + binding.checkContainer.visibility = View.GONE + } + + binding.root.setOnClickListener { + onProfileClick(profile) + } + + binding.buttonEdit.setOnClickListener { + onEditClick(profile) + } + + binding.buttonDelete.setOnClickListener { + onDeleteClick(profile) + } + } + + private fun formatUUID(uuid: String): String { + if (uuid.length != 32) return uuid + return buildString { + append(uuid.substring(0, 8)) + append("-") + append(uuid.substring(8, 12)) + append("-") + append(uuid.substring(12, 16)) + append("-") + append(uuid.substring(16, 20)) + append("-") + append(uuid.substring(20, 32)) + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EditUserDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EditUserDialogFragment.kt new file mode 100644 index 0000000000..deff55f503 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EditUserDialogFragment.kt @@ -0,0 +1,459 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.FirmwareAvatarAdapter +import org.yuzu.yuzu_emu.databinding.FragmentEditUserDialogBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.ProfileUtils +import org.yuzu.yuzu_emu.model.UserProfile +import java.io.File +import java.io.FileOutputStream +import androidx.core.graphics.scale +import androidx.core.graphics.createBitmap + +class EditUserDialogFragment : Fragment() { + private var _binding: FragmentEditUserDialogBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private var currentUUID: String = "" + private var isEditMode = false + private var selectedImageUri: Uri? = null + private var selectedFirmwareAvatar: Bitmap? = null + private var hasCustomImage = false + private var revertedToDefault = false + + companion object { + private const val ARG_UUID = "uuid" + private const val ARG_USERNAME = "username" + + fun newInstance(profile: UserProfile?): EditUserDialogFragment { + val fragment = EditUserDialogFragment() + profile?.let { + val args = Bundle() + args.putString(ARG_UUID, it.uuid) + args.putString(ARG_USERNAME, it.username) + fragment.arguments = args + } + return fragment + } + } + + private val imagePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + selectedImageUri = uri + loadImage(uri) + hasCustomImage = true + binding.buttonRevertImage.visibility = View.VISIBLE + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEditUserDialogBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + val existingUUID = arguments?.getString(ARG_UUID) + val existingUsername = arguments?.getString(ARG_USERNAME) + + if (existingUUID != null && existingUsername != null) { + isEditMode = true + currentUUID = existingUUID + binding.toolbarNewUser.title = getString(R.string.profile_edit_user) + binding.editUsername.setText(existingUsername) + binding.textUuid.text = formatUUID(existingUUID) + binding.buttonGenerateUuid.visibility = View.GONE + + val imagePath = NativeLibrary.getUserImagePath(existingUUID) + val imageFile = File(imagePath) + if (imageFile.exists()) { + val bitmap = BitmapFactory.decodeFile(imagePath) + binding.imageUserAvatar.setImageBitmap(bitmap) + hasCustomImage = true + binding.buttonRevertImage.visibility = View.VISIBLE + } else { + loadDefaultAvatar() + } + } else { + isEditMode = false + currentUUID = ProfileUtils.generateRandomUUID() + binding.toolbarNewUser.title = getString(R.string.profile_new_user) + binding.textUuid.text = formatUUID(currentUUID) + loadDefaultAvatar() + } + + binding.toolbarNewUser.setNavigationOnClickListener { + findNavController().popBackStack() + } + + binding.editUsername.doAfterTextChanged { + validateInput() + } + + binding.buttonGenerateUuid.setOnClickListener { + currentUUID = ProfileUtils.generateRandomUUID() + binding.textUuid.text = formatUUID(currentUUID) + } + + binding.buttonSelectImage.setOnClickListener { + selectImage() + } + + binding.buttonRevertImage.setOnClickListener { + revertToDefaultImage() + } + + if (NativeLibrary.isFirmwareAvailable()) { + binding.buttonFirmwareAvatars.visibility = View.VISIBLE + binding.buttonFirmwareAvatars.setOnClickListener { + showFirmwareAvatarPicker() + } + } + + binding.buttonSave.setOnClickListener { + saveUser() + } + + binding.buttonCancel.setOnClickListener { + findNavController().popBackStack() + } + + validateInput() + setInsets() + } + + private fun showFirmwareAvatarPicker() { + val dialogView = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_firmware_avatar_picker, null) + + val gridAvatars = dialogView.findViewById(R.id.grid_avatars) + val progressLoading = dialogView.findViewById(R.id.progress_loading) + val textEmpty = dialogView.findViewById(R.id.text_empty) + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.profile_firmware_avatars) + .setView(dialogView) + .setNegativeButton(android.R.string.cancel, null) + .create() + + dialog.show() + + viewLifecycleOwner.lifecycleScope.launch { + val avatars = withContext(Dispatchers.IO) { + loadFirmwareAvatars() + } + + if (avatars.isEmpty()) { + progressLoading.visibility = View.GONE + textEmpty.visibility = View.VISIBLE + } else { + progressLoading.visibility = View.GONE + gridAvatars.visibility = View.VISIBLE + + val adapter = FirmwareAvatarAdapter(avatars) { selectedAvatar -> + val scaledBitmap = selectedAvatar.scale(256, 256) + binding.imageUserAvatar.setImageBitmap(scaledBitmap) + selectedFirmwareAvatar = scaledBitmap + hasCustomImage = true + binding.buttonRevertImage.visibility = View.VISIBLE + dialog.dismiss() + } + + gridAvatars.apply { + layoutManager = GridLayoutManager(requireContext(), 4) + this.adapter = adapter + } + } + } + } + + private fun loadFirmwareAvatars(): List { + val avatars = mutableListOf() + val count = NativeLibrary.getFirmwareAvatarCount() + + for (i in 0 until count) { + try { + val imageData = NativeLibrary.getFirmwareAvatarImage(i) ?: continue + + val argbData = IntArray(256 * 256) + for (pixel in 0 until 256 * 256) { + val offset = pixel * 4 + val r = imageData[offset].toInt() and 0xFF + val g = imageData[offset + 1].toInt() and 0xFF + val b = imageData[offset + 2].toInt() and 0xFF + val a = imageData[offset + 3].toInt() and 0xFF + argbData[pixel] = (a shl 24) or (r shl 16) or (g shl 8) or b + } + + val bitmap = Bitmap.createBitmap(argbData, 256, 256, Bitmap.Config.ARGB_8888) + avatars.add(bitmap) + } catch (e: Exception) { + continue + } + } + + return avatars + } + + private fun formatUUID(uuid: String): String { + if (uuid.length != 32) return uuid + return buildString { + append(uuid.substring(0, 8)) + append("-") + append(uuid.substring(8, 12)) + append("-") + append(uuid.substring(12, 16)) + append("-") + append(uuid.substring(16, 20)) + append("-") + append(uuid.substring(20, 32)) + } + } + + private fun validateInput() { + val username = binding.editUsername.text.toString() + val isValid = username.isNotEmpty() && username.length <= 32 + binding.buttonSave.isEnabled = isValid + } + + private fun selectImage() { + val intent = Intent(Intent.ACTION_PICK).apply { + type = "image/*" + } + imagePickerLauncher.launch(intent) + } + + private fun loadImage(uri: Uri) { + try { + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(requireContext().contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.setTargetSampleSize(1) + } + } else { + @Suppress("DEPRECATION") + MediaStore.Images.Media.getBitmap(requireContext().contentResolver, uri) + } + + val croppedBitmap = centerCropBitmap(bitmap, 256, 256) + binding.imageUserAvatar.setImageBitmap(croppedBitmap) + } catch (e: Exception) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.error) + .setMessage(getString(R.string.profile_image_load_error, e.message)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + private fun loadDefaultAvatar() { + val jpegData = NativeLibrary.getDefaultAccountBackupJpeg() + val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size) + binding.imageUserAvatar.setImageBitmap(bitmap) + + hasCustomImage = false + binding.buttonRevertImage.visibility = View.GONE + } + + private fun revertToDefaultImage() { + selectedImageUri = null + selectedFirmwareAvatar = null + revertedToDefault = true + loadDefaultAvatar() + } + + private fun saveUser() { + val username = binding.editUsername.text.toString() + + if (isEditMode) { + if (NativeLibrary.updateUserUsername(currentUUID, username)) { + saveImageIfNeeded() + findNavController().popBackStack() + } else { + showError(getString(R.string.profile_update_failed)) + } + } else { + if (NativeLibrary.createUser(currentUUID, username)) { + saveImageIfNeeded() + findNavController().popBackStack() + } else { + showError(getString(R.string.profile_create_failed)) + } + } + } + + private fun saveImageIfNeeded() { + if (revertedToDefault && isEditMode) { + val imagePath = NativeLibrary.getUserImagePath(currentUUID) + if (imagePath != null) { + val imageFile = File(imagePath) + if (imageFile.exists()) { + imageFile.delete() + } + } + + return + } + + if (!hasCustomImage) { + return + } + + try { + val bitmapToSave: Bitmap? = when { + selectedFirmwareAvatar != null -> selectedFirmwareAvatar + selectedImageUri != null -> { + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource( + requireContext().contentResolver, + selectedImageUri!! + ) + ImageDecoder.decodeBitmap(source) + } else { + @Suppress("DEPRECATION") + MediaStore.Images.Media.getBitmap( + requireContext().contentResolver, + selectedImageUri + ) + } + centerCropBitmap(bitmap, 256, 256) + } + + else -> null + } + + if (bitmapToSave == null) { + return + } + + val tempFile = File(requireContext().cacheDir, "temp_avatar_${currentUUID}.jpg") + FileOutputStream(tempFile).use { out -> + bitmapToSave.compress(Bitmap.CompressFormat.JPEG, 100, out) + } + + NativeLibrary.saveUserImage(currentUUID, tempFile.absolutePath) + + tempFile.delete() + } catch (e: Exception) { + showError(getString(R.string.profile_image_save_error, e.message)) + } + } + + private fun centerCropBitmap(source: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap { + val sourceWidth = source.width + val sourceHeight = source.height + + val scale = maxOf( + targetWidth.toFloat() / sourceWidth, + targetHeight.toFloat() / sourceHeight + ) + + val scaledWidth = (sourceWidth * scale).toInt() + val scaledHeight = (sourceHeight * scale).toInt() + + val scaledBitmap = source.scale(scaledWidth, scaledHeight) + + val x = (scaledWidth - targetWidth) / 2 + val y = (scaledHeight - targetHeight) / 2 + + return Bitmap.createBitmap(scaledBitmap, x, y, targetWidth, targetHeight) + } + + private fun showError(message: String) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.error) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInset = barInsets.left + cutoutInsets.left + val topInset = cutoutInsets.top + val rightInset = barInsets.right + cutoutInsets.right + val bottomInset = barInsets.bottom + cutoutInsets.bottom + + binding.appbar.updatePadding( + left = leftInset, + top = topInset, + right = rightInset + ) + + binding.scrollContent.updatePadding( + left = leftInset, + right = rightInset + ) + + binding.buttonContainer.updatePadding( + left = leftInset, + right = rightInset, + bottom = bottomInset + ) + + windowInsets + } + + + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 464572e777..d21b4e5d91 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -114,6 +114,17 @@ class HomeSettingsFragment : Fragment() { } ) ) + add( + HomeSetting( + R.string.profile_manager, + R.string.profile_manager_description, + R.drawable.ic_account_circle, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_profileManagerFragment) + } + ) + ) add( HomeSetting( R.string.gpu_driver_manager, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt new file mode 100644 index 0000000000..6ee34105e7 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.ProfileAdapter +import org.yuzu.yuzu_emu.databinding.FragmentProfileManagerBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.UserProfile +import org.yuzu.yuzu_emu.utils.NativeConfig + +class ProfileManagerFragment : Fragment() { + private var _binding: FragmentProfileManagerBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private lateinit var profileAdapter: ProfileAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentProfileManagerBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarProfiles.setNavigationOnClickListener { + findNavController().popBackStack() + } + + setupRecyclerView() + loadProfiles() + + binding.buttonAddUser.setOnClickListener { + if (NativeLibrary.canCreateUser()) { + findNavController().navigate(R.id.action_profileManagerFragment_to_newUserDialog) + } else { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.profile_max_users_title) + .setMessage(R.string.profile_max_users_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + setInsets() + } + + override fun onResume() { + super.onResume() + loadProfiles() + } + + private fun setupRecyclerView() { + profileAdapter = ProfileAdapter( + onProfileClick = { profile -> selectProfile(profile) }, + onEditClick = { profile -> editProfile(profile) }, + onDeleteClick = { profile -> confirmDeleteProfile(profile) } + ) + binding.listProfiles.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = profileAdapter + } + } + + private fun loadProfiles() { + val profiles = mutableListOf() + val userUUIDs = NativeLibrary.getAllUsers() ?: emptyArray() + val currentUserUUID = NativeLibrary.getCurrentUser() + + for (uuid in userUUIDs) { + if (uuid.isNotEmpty()) { + val username = NativeLibrary.getUserUsername(uuid) + if (!username.isNullOrEmpty()) { + val imagePath = NativeLibrary.getUserImagePath(uuid) ?: "" + profiles.add(UserProfile(uuid, username, imagePath)) + } + } + } + + profileAdapter.submitList(profiles) + profileAdapter.setCurrentUser(currentUserUUID ?: "") + + binding.buttonAddUser.isEnabled = NativeLibrary.canCreateUser() + } + + private fun selectProfile(profile: UserProfile) { + if (NativeLibrary.setCurrentUser(profile.uuid)) { + loadProfiles() + } + } + + + private fun editProfile(profile: UserProfile) { + val bundle = Bundle().apply { + putString("uuid", profile.uuid) + putString("username", profile.username) + } + findNavController().navigate(R.id.action_profileManagerFragment_to_newUserDialog, bundle) + } + + private fun confirmDeleteProfile(profile: UserProfile) { + val currentUser = NativeLibrary.getCurrentUser() + val isCurrentUser = profile.uuid == currentUser + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.profile_delete_confirm_title) + .setMessage( + if (isCurrentUser) { + getString(R.string.profile_delete_current_user_message, profile.username) + } else { + getString(R.string.profile_delete_confirm_message, profile.username) + } + ) + .setPositiveButton(R.string.profile_delete) { _, _ -> + deleteProfile(profile) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun deleteProfile(profile: UserProfile) { + val currentUser = NativeLibrary.getCurrentUser() + if (!currentUser.isNullOrEmpty() && profile.uuid == currentUser) { + val users = NativeLibrary.getAllUsers() ?: emptyArray() + for (uuid in users) { + if (uuid.isNotEmpty() && uuid != profile.uuid) { + NativeLibrary.setCurrentUser(uuid) + break + } + } + } + + if (NativeLibrary.removeUser(profile.uuid)) { + loadProfiles() + } + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val fabLayoutParams = binding.buttonAddUser.layoutParams as ViewGroup.MarginLayoutParams + fabLayoutParams.leftMargin = leftInsets + 24 + fabLayoutParams.rightMargin = rightInsets + 24 + fabLayoutParams.bottomMargin = barInsets.bottom + 24 + binding.buttonAddUser.layoutParams = fabLayoutParams + + windowInsets + } + } + + override fun onDestroyView() { + super.onDestroyView() + NativeConfig.saveGlobalConfig() + _binding = null + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/UserProfile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/UserProfile.kt new file mode 100644 index 0000000000..d45874816c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/UserProfile.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UserProfile( + val uuid: String, + val username: String, + val imagePath: String = "" +) : Parcelable + +object ProfileUtils { + fun generateRandomUUID(): String { + val uuid = java.util.UUID.randomUUID() + return uuid.toString().replace("-", "") + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt index 5325f688b6..6318aa71f2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.utils @@ -25,6 +25,7 @@ object DirectoryInitialization { initializeInternalStorage() NativeLibrary.initializeSystem(false) NativeConfig.initializeGlobalConfig() + NativeLibrary.reloadProfiles() migrateSettings() areDirectoriesReady = true } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 37f195fe15..ac1a189e75 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -49,12 +49,14 @@ #include "common/settings.h" #include "common/string_util.h" #include "frontend_common/play_time_manager.h" +#include "core/constants.h" #include "core/core.h" #include "core/cpu_manager.h" #include "core/crypto/key_manager.h" #include "core/file_sys/card_image.h" #include "core/file_sys/content_archive.h" #include "core/file_sys/fs_filesystem.h" +#include "core/file_sys/romfs.h" #include "core/file_sys/submission_package.h" #include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs_real.h" @@ -1697,4 +1699,388 @@ JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getBuildVersion( return env->NewStringUTF(Common::g_build_version); } +JNIEXPORT jobjectArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getAllUsers( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + + manager.ResetUserSaveFile(); + + if (manager.GetUserCount() == 0) { + manager.CreateNewUser(Common::UUID::MakeRandom(), "Eden"); + manager.WriteUserSaveFile(); + } + + const auto& users = manager.GetAllUsers(); + + jclass string_class = env->FindClass("java/lang/String"); + if (!string_class) { + return env->NewObjectArray(0, env->FindClass("java/lang/Object"), nullptr); + } + + jsize valid_count = 0; + for (const auto& user : users) { + if (user.IsValid()) { + valid_count++; + } + } + + jobjectArray result = env->NewObjectArray(valid_count, string_class, nullptr); + if (!result) { + return env->NewObjectArray(0, string_class, nullptr); + } + + // fill array sequentially with only valid users + jsize array_index = 0; + for (const auto& user : users) { + if (user.IsValid()) { + jstring uuid_str = env->NewStringUTF(user.FormattedString().c_str()); + if (uuid_str) { + env->SetObjectArrayElement(result, array_index++, uuid_str); + } + } + } + + return result; +} + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUserUsername( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto uuid = Common::UUID{uuid_string}; + + Service::Account::ProfileBase profile{}; + if (!manager.GetProfileBase(uuid, profile)) { + jstring result = env->NewStringUTF(""); + return result ? result : env->NewStringUTF(""); + } + + const auto text = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast(profile.username.data()), profile.username.size()); + jstring result = env->NewStringUTF(text.c_str()); + return result ? result : env->NewStringUTF(""); +} + +JNIEXPORT jlong JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUserCount( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + return static_cast(manager.GetUserCount()); +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_canCreateUser( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + return manager.CanSystemRegisterUser(); +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_createUser( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid, + jstring jusername) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto username = Common::Android::GetJString(env, jusername); + const auto uuid = Common::UUID{uuid_string}; + + const auto result = manager.CreateNewUser(uuid, username); + if (result.IsSuccess()) { + manager.WriteUserSaveFile(); + return true; + } + return false; +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_updateUserUsername( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid, + jstring jusername) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto username = Common::Android::GetJString(env, jusername); + const auto uuid = Common::UUID{uuid_string}; + + Service::Account::ProfileBase profile{}; + if (!manager.GetProfileBase(uuid, profile)) { + return false; + } + + std::fill(profile.username.begin(), profile.username.end(), '\0'); + std::copy(username.begin(), username.end(), profile.username.begin()); + + if (manager.SetProfileBase(uuid, profile)) { + manager.WriteUserSaveFile(); + return true; + } + return false; +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_removeUser( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto uuid = Common::UUID{uuid_string}; + + const auto user_index = manager.GetUserIndex(uuid); + if (!user_index) { + return false; + } + + if (Settings::values.current_user.GetValue() == static_cast(*user_index)) { + Settings::values.current_user = 0; + } + + if (manager.RemoveUser(uuid)) { + manager.WriteUserSaveFile(); + manager.ResetUserSaveFile(); + return true; + } + return false; +} + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getCurrentUser( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto user_id = manager.GetUser(Settings::values.current_user.GetValue()); + if (!user_id) { + jstring result = env->NewStringUTF(""); + return result ? result : env->NewStringUTF(""); + } + jstring result = env->NewStringUTF(user_id->FormattedString().c_str()); + return result ? result : env->NewStringUTF(""); +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_setCurrentUser( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto uuid = Common::UUID{uuid_string}; + + const auto index = manager.GetUserIndex(uuid); + if (index) { + Settings::values.current_user = static_cast(*index); + return true; + } + return false; +} + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUserImagePath( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid) { + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto uuid = Common::UUID{uuid_string}; + + const auto path = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir) / + fmt::format("system/save/8000000000000010/su/avators/{}.jpg", uuid.FormattedString()); + + jstring result = Common::Android::ToJString(env, Common::FS::PathToUTF8String(path)); + return result ? result : env->NewStringUTF(""); +} + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_saveUserImage( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring juuid, + jstring jimagePath) { + const auto uuid_string = Common::Android::GetJString(env, juuid); + const auto uuid = Common::UUID{uuid_string}; + const auto image_source = Common::Android::GetJString(env, jimagePath); + + const auto dest_path = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir) / + fmt::format("system/save/8000000000000010/su/avators/{}.jpg", uuid.FormattedString()); + + const auto dest_dir = dest_path.parent_path(); + if (!Common::FS::CreateDirs(dest_dir)) { + return false; + } + + try { + std::filesystem::copy_file(image_source, dest_path, + std::filesystem::copy_options::overwrite_existing); + return true; + } catch (const std::filesystem::filesystem_error& e) { + LOG_ERROR(Common_Filesystem, "Failed to copy image file: {}", e.what()); + return false; + } +} + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_reloadProfiles( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + auto& manager = EmulationSession::GetInstance().System().GetProfileManager(); + manager.ResetUserSaveFile(); + + // create a default user if non exist + if (manager.GetUserCount() == 0) { + manager.CreateNewUser(Common::UUID::MakeRandom(), "Eden"); + manager.WriteUserSaveFile(); + } + + LOG_INFO(Service_ACC, "Profile manager reloaded, user count: {}", manager.GetUserCount()); +} + +// for firmware avatar images +static std::vector DecompressYaz0(const FileSys::VirtualFile& file) { + if (!file) { + return std::vector(); + } + + uint32_t magic{}; + file->ReadObject(&magic, 0); + if (magic != Common::MakeMagic('Y', 'a', 'z', '0')) { + return std::vector(); + } + + uint32_t decoded_length{}; + file->ReadObject(&decoded_length, 4); + decoded_length = Common::swap32(decoded_length); + + std::size_t input_size = file->GetSize() - 16; + std::vector input(input_size); + file->ReadBytes(input.data(), input_size, 16); + + uint32_t input_offset{}; + uint32_t output_offset{}; + std::vector output(decoded_length); + + uint16_t mask{}; + uint8_t header{}; + + while (output_offset < decoded_length) { + if ((mask >>= 1) == 0) { + if (input_offset >= input.size()) break; + header = input[input_offset++]; + mask = 0x80; + } + + if ((header & mask) != 0) { + if (output_offset >= output.size() || input_offset >= input.size()) { + break; + } + output[output_offset++] = input[input_offset++]; + } else { + if (input_offset + 1 >= input.size()) break; + uint8_t byte1 = input[input_offset++]; + uint8_t byte2 = input[input_offset++]; + + uint32_t dist = ((byte1 & 0xF) << 8) | byte2; + uint32_t position = output_offset - (dist + 1); + + uint32_t length = byte1 >> 4; + if (length == 0) { + if (input_offset >= input.size()) break; + length = static_cast(input[input_offset++]) + 0x12; + } else { + length += 2; + } + + for (uint32_t i = 0; i < length && output_offset < decoded_length; ++i) { + output[output_offset++] = output[position++]; + } + } + } + + return output; +} + +static FileSys::VirtualDir GetFirmwareAvatarDirectory() { + constexpr u64 AvatarImageDataId = 0x010000000000080AULL; + + auto* bis_system = EmulationSession::GetInstance().System().GetFileSystemController().GetSystemNANDContents(); + if (!bis_system) { + return nullptr; + } + + const auto nca = bis_system->GetEntry(AvatarImageDataId, FileSys::ContentRecordType::Data); + if (!nca) { + return nullptr; + } + + const auto romfs = nca->GetRomFS(); + if (!romfs) { + return nullptr; + } + + const auto extracted = FileSys::ExtractRomFS(romfs); + if (!extracted) { + return nullptr; + } + + return extracted->GetSubdirectory("chara"); +} + +JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getFirmwareAvatarCount( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + const auto chara_dir = GetFirmwareAvatarDirectory(); + if (!chara_dir) { + return 0; + } + + int count = 0; + for (const auto& item : chara_dir->GetFiles()) { + if (item->GetExtension() == "szs") { + count++; + } + } + return count; +} + +JNIEXPORT jbyteArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getFirmwareAvatarImage( + JNIEnv* env, + [[maybe_unused]] jobject obj, + jint index) { + const auto chara_dir = GetFirmwareAvatarDirectory(); + if (!chara_dir) { + return nullptr; + } + + int current_index = 0; + for (const auto& item : chara_dir->GetFiles()) { + if (item->GetExtension() != "szs") { + continue; + } + + if (current_index == index) { + auto image_data = DecompressYaz0(item); + if (image_data.empty()) { + return nullptr; + } + + jbyteArray result = env->NewByteArray(image_data.size()); + if (result) { + env->SetByteArrayRegion(result, 0, image_data.size(), + reinterpret_cast(image_data.data())); + } + return result; + } + current_index++; + } + + return nullptr; +} + +JNIEXPORT jbyteArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getDefaultAccountBackupJpeg( + JNIEnv* env, + [[maybe_unused]] jobject obj) { + jbyteArray result = env->NewByteArray(Core::Constants::ACCOUNT_BACKUP_JPEG.size()); + if (result) { + env->SetByteArrayRegion(result, 0, Core::Constants::ACCOUNT_BACKUP_JPEG.size(), + reinterpret_cast(Core::Constants::ACCOUNT_BACKUP_JPEG.data())); + } + return result; +} + } // extern "C" diff --git a/src/android/app/src/main/res/drawable/ic_account_circle.xml b/src/android/app/src/main/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000000..f2b564d6f8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_account_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/layout/dialog_firmware_avatar_picker.xml b/src/android/app/src/main/res/layout/dialog_firmware_avatar_picker.xml new file mode 100644 index 0000000000..98af9b9d6b --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_firmware_avatar_picker.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_edit_user_dialog.xml b/src/android/app/src/main/res/layout/fragment_edit_user_dialog.xml new file mode 100644 index 0000000000..0111d23bc7 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_edit_user_dialog.xml @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_profile_manager.xml b/src/android/app/src/main/res/layout/fragment_profile_manager.xml new file mode 100644 index 0000000000..e98b4d133a --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_profile_manager.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_firmware_avatar.xml b/src/android/app/src/main/res/layout/item_firmware_avatar.xml new file mode 100644 index 0000000000..a8a31faa9a --- /dev/null +++ b/src/android/app/src/main/res/layout/item_firmware_avatar.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/android/app/src/main/res/layout/list_item_profile.xml b/src/android/app/src/main/res/layout/list_item_profile.xml new file mode 100644 index 0000000000..7f4a1c7ea4 --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_profile.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml index 97ff52a124..873438e7ae 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -36,6 +36,9 @@ + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 3765b36323..2967b3fcd1 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -1239,6 +1239,35 @@ Enable Overlay Applet Enables Horizon\'s built-in overlay applet. Press and hold the home button for 1 second to show it. + + Profile Manager + Manage user profiles + Add User + New User + Edit User + Edit + Delete + Username + User ID (UUID) + This is the unique identifier for this user profile. It cannot be changed after creation. + Generate + User Avatar + Select Image + Firmware Avatars + Firmware avatars are not available. Please install firmware to use this feature. + Revert to Default + Current User + Maximum Users Reached + You cannot create more than 8 user profiles. Please delete an existing profile to create a new one. + Delete Profile? + Are you sure you want to delete %1$s? All save data for this user will be deleted. + Are you sure you want to delete %1$s? This is the currently selected user. The first available user will be selected instead. + Failed to create user profile + Failed to update user profile + Failed to load image: %1$s + Failed to save image: %1$s + Error + Licenses FidelityFX-FSR