From eafe4061533473fb9d2c5b73e8337549dc49d488 Mon Sep 17 00:00:00 2001 From: Kleidis <167202775+kleidis@users.noreply.github.com> Date: Thu, 6 Mar 2025 06:20:51 +0100 Subject: [PATCH] android: Enhance shortcut customization with a custom dialog Adds ability to customize game shortcuts with: - Custom name input - Editable icon via image picker - Ability to stretch to fit or zoom to fit the shortcut icon --- .../citra/citra_emu/adapters/GameAdapter.kt | 136 +++++++++++++++--- .../citra_emu/fragments/GamesFragment.kt | 17 ++- .../citra_emu/fragments/SearchFragment.kt | 17 ++- .../res/drawable/shortcut_edit_background.xml | 15 ++ .../src/main/res/layout/dialog_shortcut.xml | 61 ++++++++ .../app/src/main/res/values/strings.xml | 6 + 6 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 src/android/app/src/main/res/drawable/shortcut_edit_background.xml create mode 100644 src/android/app/src/main/res/layout/dialog_shortcut.xml diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index f3400e2f3..a4e61b95f 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -19,6 +19,8 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.Bitmap import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager +import android.graphics.BitmapFactory +import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModelProvider @@ -41,17 +43,30 @@ import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.R import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder import org.citra.citra_emu.databinding.CardGameBinding +import org.citra.citra_emu.databinding.DialogShortcutBinding import org.citra.citra_emu.features.cheats.ui.CheatsFragmentDirections -import org.citra.citra_emu.features.settings.ui.SettingsActivity -import org.citra.citra_emu.features.settings.utils.SettingsFile import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel +import androidx.core.net.toUri +import androidx.core.graphics.scale +import androidx.core.content.edit -class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater) : +class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater, private val openImageLauncher: ActivityResultLauncher?) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), View.OnClickListener, View.OnLongClickListener { private var lastClickTime = 0L + private var imagePath: String? = null + private var dialogShortcutBinding: DialogShortcutBinding? = null + + fun handleShortcutImageResult(uri: Uri?) { + val path = uri?.toString() + if (path != null) { + imagePath = path + dialogShortcutBinding!!.imageScaleSwitch.isEnabled = imagePath != null + refreshShortcutDialogIcon() + } + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { // Create a new view. @@ -222,23 +237,70 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: } bottomSheetView.findViewById(R.id.game_shortcut).setOnClickListener { - val shortcutManager = activity.getSystemService(ShortcutManager::class.java) + val preferences = PreferenceManager.getDefaultSharedPreferences(context) - CoroutineScope(Dispatchers.IO).launch { - val bitmap = (bottomSheetView.findViewById(R.id.game_icon).drawable as BitmapDrawable).bitmap - val icon = Icon.createWithBitmap(bitmap) - - val shortcut = ShortcutInfo.Builder(context, game.title) - .setShortLabel(game.title) - .setIcon(icon) - .setIntent(game.launchIntent.apply { - putExtra("launched_from_shortcut", true) - }) - .build() - shortcutManager.requestPinShortcut(shortcut, null) + // Default to false for zoomed in shortcut icons + preferences.edit() { + putBoolean( + "shouldStretchIcon", + false + ) } + + dialogShortcutBinding = DialogShortcutBinding.inflate(activity.layoutInflater) + + dialogShortcutBinding!!.shortcutNameInput.setText(game.title) + GameIconUtils.loadGameIcon(activity, game, dialogShortcutBinding!!.shortcutIcon) + + dialogShortcutBinding!!.shortcutIcon.setOnClickListener { + openImageLauncher?.launch("image/*") + } + + dialogShortcutBinding!!.imageScaleSwitch.setOnCheckedChangeListener { _, isChecked -> + preferences.edit { + putBoolean( + "shouldStretchIcon", + isChecked + ) + } + refreshShortcutDialogIcon() + } + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.create_shortcut) + .setView(dialogShortcutBinding!!.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + val shortcutName = dialogShortcutBinding!!.shortcutNameInput.text.toString() + if (shortcutName.isEmpty()) { + Toast.makeText(context, R.string.shortcut_name_empty, Toast.LENGTH_LONG).show() + return@setPositiveButton + } + val iconBitmap = (dialogShortcutBinding!!.shortcutIcon.drawable as BitmapDrawable).bitmap + val shortcutManager = activity.getSystemService(ShortcutManager::class.java) + + CoroutineScope(Dispatchers.IO).launch { + val icon = Icon.createWithBitmap(iconBitmap) + val shortcut = ShortcutInfo.Builder(context, shortcutName) + .setShortLabel(shortcutName) + .setIcon(icon) + .setIntent(game.launchIntent.apply { + putExtra("launchedFromShortcut", true) + }) + .build() + + shortcutManager?.requestPinShortcut(shortcut, null) + imagePath = null + } + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + imagePath = null + } + .show() + + bottomSheetDialog.dismiss() } + bottomSheetView.findViewById(R.id.cheats).setOnClickListener { val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId) view.findNavController().navigate(action) @@ -252,6 +314,48 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: bottomSheetDialog.show() } + private fun refreshShortcutDialogIcon() { + if (imagePath != null) { + val originalBitmap = BitmapFactory.decodeStream( + CitraApplication.appContext.contentResolver.openInputStream( + imagePath!!.toUri() + ) + ) + val scaledBitmap = { + val preferences = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + if (preferences.getBoolean("shouldStretchIcon", true)) { + // stretch to fit + originalBitmap.scale(108, 108) + } else { + // Zoom in to fit the bitmap while keeping the aspect ratio + val width = originalBitmap.width + val height = originalBitmap.height + val targetSize = 108 + + if (width > height) { + // Landscape orientation + val scaleFactor = targetSize.toFloat() / height + val scaledWidth = (width * scaleFactor).toInt() + val scaledBmp = originalBitmap.scale(scaledWidth, targetSize) + + val startX = (scaledWidth - targetSize) / 2 + Bitmap.createBitmap(scaledBmp, startX, 0, targetSize, targetSize) + } else { + val scaleFactor = targetSize.toFloat() / width + val scaledHeight = (height * scaleFactor).toInt() + val scaledBmp = originalBitmap.scale(targetSize, scaledHeight) + + val startY = (scaledHeight - targetSize) / 2 + Bitmap.createBitmap(scaledBmp, 0, startY, targetSize, targetSize) + } + } + }() + dialogShortcutBinding!!.shortcutIcon.setImageBitmap(scaledBitmap) + } + } + + private fun isValidGame(extension: String): Boolean { return Game.badExtensions.stream() .noneMatch { extension == it.lowercase() } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt index 70d882ef2..b2139c322 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt @@ -10,6 +10,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -33,6 +34,7 @@ import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.model.Game import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel +import android.net.Uri class GamesFragment : Fragment() { private var _binding: FragmentGamesBinding? = null @@ -40,6 +42,13 @@ class GamesFragment : Fragment() { private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() + private lateinit var gameAdapter: GameAdapter + + private val openImageLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + gameAdapter.handleShortcutImageResult(uri) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -63,12 +72,18 @@ class GamesFragment : Fragment() { val inflater = LayoutInflater.from(requireContext()) + gameAdapter = GameAdapter( + requireActivity() as AppCompatActivity, + inflater, + openImageLauncher + ) + binding.gridGames.apply { layoutManager = GridLayoutManager( requireContext(), resources.getInteger(R.integer.game_grid_columns) ) - adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) + adapter = this@GamesFragment.gameAdapter } binding.swipeRefresh.apply { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt index 464a60e87..8cff6f2bd 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt @@ -36,6 +36,8 @@ import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel import java.time.temporal.ChronoField import java.util.Locale +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts class SearchFragment : Fragment() { private var _binding: FragmentSearchBinding? = null @@ -43,6 +45,13 @@ class SearchFragment : Fragment() { private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() + private lateinit var gameAdapter: GameAdapter + + private val openImageLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + gameAdapter.handleShortcutImageResult(uri) + } private lateinit var preferences: SharedPreferences @@ -73,12 +82,18 @@ class SearchFragment : Fragment() { val inflater = LayoutInflater.from(requireContext()) + gameAdapter = GameAdapter( + requireActivity() as AppCompatActivity, + inflater, + openImageLauncher + ) + binding.gridGamesSearch.apply { layoutManager = GridLayoutManager( requireContext(), resources.getInteger(R.integer.game_grid_columns) ) - adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) + adapter = this@SearchFragment.gameAdapter } binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } diff --git a/src/android/app/src/main/res/drawable/shortcut_edit_background.xml b/src/android/app/src/main/res/drawable/shortcut_edit_background.xml new file mode 100644 index 000000000..fd5e4849f --- /dev/null +++ b/src/android/app/src/main/res/drawable/shortcut_edit_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_shortcut.xml b/src/android/app/src/main/res/layout/dialog_shortcut.xml new file mode 100644 index 000000000..c2369d14a --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_shortcut.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5495a2a23..971de06b1 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -477,6 +477,12 @@ Play Shortcut + Shortcut Name + Edit icon + Create Shortcut + Shortcut name cannot be empty + Stretch to fit image + Cheats