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