From 9ecd26d2ce13704dac236d2a25650476654b5db3 Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Thu, 29 May 2025 18:17:01 +0000 Subject: [PATCH] android: Enhance shortcut customization with a custom dialog (#824) * 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 * Code cleanup * SearchFragment.kt: Updated license header --------- Co-authored-by: Kleidis <167202775+kleidis@users.noreply.github.com> --- .../citra/citra_emu/adapters/GameAdapter.kt | 134 +++++++++++++++--- .../citra_emu/fragments/GamesFragment.kt | 21 ++- .../citra_emu/fragments/SearchFragment.kt | 19 ++- .../res/drawable/shortcut_edit_background.xml | 15 ++ .../src/main/res/layout/dialog_shortcut.xml | 61 ++++++++ .../app/src/main/res/values/strings.xml | 7 +- 6 files changed, 235 insertions(+), 22 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 abb5b91b8..f99056f47 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 @@ -20,7 +20,12 @@ 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.core.content.edit +import androidx.core.graphics.scale +import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController @@ -43,19 +48,29 @@ 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.fragments.IndeterminateProgressDialogFragment import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel -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. @@ -337,21 +352,67 @@ 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 { @@ -375,6 +436,47 @@ 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 382a42773..b224c5c15 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 @@ -5,6 +5,7 @@ package org.citra.citra_emu.fragments import android.annotation.SuppressLint +import android.net.Uri import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.LayoutInflater @@ -12,7 +13,10 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.text.HtmlCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding @@ -36,8 +40,6 @@ 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 androidx.core.content.edit -import androidx.core.text.HtmlCompat class GamesFragment : Fragment() { private var _binding: FragmentGamesBinding? = null @@ -46,6 +48,13 @@ class GamesFragment : Fragment() { private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() private var show3DSFileWarning: Boolean = true + private lateinit var gameAdapter: GameAdapter + + private val openImageLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + gameAdapter.handleShortcutImageResult(uri) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,12 +78,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..94821023f 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 @@ -1,4 +1,4 @@ -// Copyright 2023 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -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 e1d1d2a95..3d47200be 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -541,7 +541,6 @@ Play - Shortcut Uninstall Application Uninstalling... Open Save Data Folder @@ -553,6 +552,12 @@ Open Extra Folder Uninstall DLC Uninstall Updates + Shortcut + Shortcut Name + Edit icon + Create Shortcut + Shortcut name cannot be empty + Stretch to fit image Show Performance Overlay