[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 <lizzie@eden-emu.dev> Reviewed-by: DraVee <dravee@eden-emu.dev> Co-authored-by: nekle <nekle@protonmail.com> Co-committed-by: nekle <nekle@protonmail.com>
This commit is contained in:
parent
1ab7fdb157
commit
ce2ca3e522
|
|
@ -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<String>?
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Bitmap>,
|
||||
private val onAvatarSelected: (Bitmap) -> Unit
|
||||
) : RecyclerView.Adapter<FirmwareAvatarAdapter.AvatarViewHolder>() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProfileAdapter.ProfileViewHolder>() {
|
||||
|
||||
private var currentUserUUID: String = ""
|
||||
|
||||
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<UserProfile>() {
|
||||
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<UserProfile>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RecyclerView>(R.id.grid_avatars)
|
||||
val progressLoading = dialogView.findViewById<View>(R.id.progress_loading)
|
||||
val textEmpty = dialogView.findViewById<View>(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<Bitmap> {
|
||||
val avatars = mutableListOf<Bitmap>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<UserProfile>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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("-", "")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<const char*>(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<jlong>(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<s32>(*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<s32>(*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<uint8_t> DecompressYaz0(const FileSys::VirtualFile& file) {
|
||||
if (!file) {
|
||||
return std::vector<uint8_t>();
|
||||
}
|
||||
|
||||
uint32_t magic{};
|
||||
file->ReadObject(&magic, 0);
|
||||
if (magic != Common::MakeMagic('Y', 'a', 'z', '0')) {
|
||||
return std::vector<uint8_t>();
|
||||
}
|
||||
|
||||
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<uint8_t> input(input_size);
|
||||
file->ReadBytes(input.data(), input_size, 16);
|
||||
|
||||
uint32_t input_offset{};
|
||||
uint32_t output_offset{};
|
||||
std::vector<uint8_t> 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<uint32_t>(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<const jbyte*>(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<const jbyte*>(Core::Constants::ACCOUNT_BACKUP_JPEG.data()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3s-3,-1.34 -3,-3S10.34,5 12,5zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22c0.03,-1.99 4,-3.08 6,-3.08c1.99,0 5.97,1.09 6,3.08C16.71,17.92 14.5,19.2 12,19.2z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/grid_avatars"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/profile_firmware_avatars_unavailable"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar_new_user"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/profile_new_user"
|
||||
app:navigationIcon="@drawable/ic_back"
|
||||
app:titleCentered="false" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroll_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="88dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="16dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="128dp"
|
||||
android:layout_height="128dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="24dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="64dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_user_avatar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/profile_avatar"
|
||||
tools:src="@drawable/ic_account_circle" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_select_image"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/profile_select_image"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:icon="@drawable/ic_add"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_firmware_avatars"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/profile_firmware_avatars"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:icon="@drawable/ic_account_circle"
|
||||
android:layout_marginStart="4dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_revert_image"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/profile_revert_image"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:hint="@string/profile_username">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/edit_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:maxLength="32"
|
||||
android:maxLines="1"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/profile_uuid"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_uuid"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true"
|
||||
tools:text="12345678-1234-1234-1234-123456789012" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_generate_uuid"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/profile_generate"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
app:icon="@drawable/ic_refresh"
|
||||
app:iconGravity="textStart" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/profile_uuid_description"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/button_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="0dp"
|
||||
app:cardElevation="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/button_container"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_cancel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@android:string/cancel"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_save"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/save"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar_profiles"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/profile_manager"
|
||||
app:navigationIcon="@drawable/ic_back"
|
||||
app:titleCentered="false" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/list_profiles"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="96dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
tools:listitem="@layout/list_item_profile" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/button_add_user"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="24dp"
|
||||
android:text="@string/profile_add_user"
|
||||
android:contentDescription="@string/profile_add_user"
|
||||
app:icon="@drawable/ic_add"
|
||||
app:iconGravity="start"
|
||||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="4dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:checkable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_avatar"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/profile_avatar" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="6dp"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
app:cardCornerRadius="16dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/avatar_container"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="28dp"
|
||||
app:cardElevation="1dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_avatar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
tools:src="@drawable/ic_account_circle"
|
||||
android:contentDescription="@string/profile_avatar" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/check_container"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
app:cardBackgroundColor="?attr/colorPrimary"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="2dp"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar_container"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar_container"
|
||||
tools:visibility="visible">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon_check"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/ic_check"
|
||||
app:tint="?attr/colorOnPrimary"
|
||||
android:contentDescription="@string/profile_current_user" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/text_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar_container"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_edit"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar_container"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar_container">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
tools:text="User Name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_uuid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="middle"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
tools:text="12345678-1234-1234-1234-123456789012" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_edit"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
app:icon="@drawable/ic_edit"
|
||||
app:iconTint="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_delete"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:contentDescription="@string/profile_edit" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_delete"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
app:icon="@drawable/ic_delete"
|
||||
app:iconTint="?attr/colorError"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:contentDescription="@string/profile_delete" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
|
@ -36,6 +36,9 @@
|
|||
<action
|
||||
android:id="@+id/action_homeSettingsFragment_to_gameFoldersFragment"
|
||||
app:destination="@id/gameFoldersFragment" />
|
||||
<action
|
||||
android:id="@+id/action_homeSettingsFragment_to_profileManagerFragment"
|
||||
app:destination="@id/profileManagerFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
|
|
@ -186,4 +189,17 @@
|
|||
app:nullable="true"
|
||||
android:defaultValue="@null" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/profileManagerFragment"
|
||||
android:name="org.yuzu.yuzu_emu.fragments.ProfileManagerFragment"
|
||||
android:label="ProfileManagerFragment" >
|
||||
<action
|
||||
android:id="@+id/action_profileManagerFragment_to_newUserDialog"
|
||||
app:destination="@id/newUserDialogFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/newUserDialogFragment"
|
||||
android:name="org.yuzu.yuzu_emu.fragments.EditUserDialogFragment"
|
||||
android:label="NewUserDialogFragment" />
|
||||
|
||||
</navigation>
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,35 @@
|
|||
<string name="enable_overlay">Enable Overlay Applet</string>
|
||||
<string name="enable_overlay_description">Enables Horizon\'s built-in overlay applet. Press and hold the home button for 1 second to show it.</string>
|
||||
|
||||
<!-- Profile Management -->
|
||||
<string name="profile_manager">Profile Manager</string>
|
||||
<string name="profile_manager_description">Manage user profiles</string>
|
||||
<string name="profile_add_user">Add User</string>
|
||||
<string name="profile_new_user">New User</string>
|
||||
<string name="profile_edit_user">Edit User</string>
|
||||
<string name="profile_edit">Edit</string>
|
||||
<string name="profile_delete">Delete</string>
|
||||
<string name="profile_username">Username</string>
|
||||
<string name="profile_uuid">User ID (UUID)</string>
|
||||
<string name="profile_uuid_description">This is the unique identifier for this user profile. It cannot be changed after creation.</string>
|
||||
<string name="profile_generate">Generate</string>
|
||||
<string name="profile_avatar">User Avatar</string>
|
||||
<string name="profile_select_image">Select Image</string>
|
||||
<string name="profile_firmware_avatars">Firmware Avatars</string>
|
||||
<string name="profile_firmware_avatars_unavailable">Firmware avatars are not available. Please install firmware to use this feature.</string>
|
||||
<string name="profile_revert_image">Revert to Default</string>
|
||||
<string name="profile_current_user">Current User</string>
|
||||
<string name="profile_max_users_title">Maximum Users Reached</string>
|
||||
<string name="profile_max_users_message">You cannot create more than 8 user profiles. Please delete an existing profile to create a new one.</string>
|
||||
<string name="profile_delete_confirm_title">Delete Profile?</string>
|
||||
<string name="profile_delete_confirm_message">Are you sure you want to delete %1$s? All save data for this user will be deleted.</string>
|
||||
<string name="profile_delete_current_user_message">Are you sure you want to delete %1$s? This is the currently selected user. The first available user will be selected instead.</string>
|
||||
<string name="profile_create_failed">Failed to create user profile</string>
|
||||
<string name="profile_update_failed">Failed to update user profile</string>
|
||||
<string name="profile_image_load_error">Failed to load image: %1$s</string>
|
||||
<string name="profile_image_save_error">Failed to save image: %1$s</string>
|
||||
<string name="error">Error</string>
|
||||
|
||||
<!-- Licenses screen strings -->
|
||||
<string name="licenses">Licenses</string>
|
||||
<string name="license_fidelityfx_fsr" translatable="false">FidelityFX-FSR</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue