From ce2ca3e5221cb572e7ea0155ba5dcee731ec6623 Mon Sep 17 00:00:00 2001 From: nekle Date: Thu, 5 Feb 2026 02:17:08 +0100 Subject: [PATCH] [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 | 16 + .../app/src/main/res/values/strings.xml | 29 ++ 17 files changed, 1769 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 b267a597e1..918478bf85 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 @@ -117,6 +117,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 e9ccbb6018..e8b12a2348 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -49,6 +49,7 @@ #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" @@ -58,6 +59,7 @@ #include "core/file_sys/fs_filesystem.h" #include "core/file_sys/nca_metadata.h" #include "core/file_sys/romfs.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" @@ -1793,4 +1795,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 e538002a70..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 6a7bbe5de9..10b3c7fcc9 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