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>
This commit is contained in:
OpenSauce 2025-05-29 18:17:01 +00:00 committed by GitHub
parent 4cfb5c9d55
commit 9ecd26d2ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 235 additions and 22 deletions

View File

@ -20,7 +20,12 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.Bitmap import android.graphics.Bitmap
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.graphics.BitmapFactory
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AppCompatActivity 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.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.navigation.findNavController 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.R
import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder
import org.citra.citra_emu.databinding.CardGameBinding 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.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.fragments.IndeterminateProgressDialogFragment
import org.citra.citra_emu.model.Game import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.utils.GameIconUtils
import org.citra.citra_emu.viewmodel.GamesViewModel 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<String>?) :
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()), ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
View.OnClickListener, View.OnLongClickListener { View.OnClickListener, View.OnLongClickListener {
private var lastClickTime = 0L 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
// Create a new view. // Create a new view.
@ -337,22 +352,68 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
} }
bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener { bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
// 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) val shortcutManager = activity.getSystemService(ShortcutManager::class.java)
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val bitmap = (bottomSheetView.findViewById<ImageView>(R.id.game_icon).drawable as BitmapDrawable).bitmap val icon = Icon.createWithBitmap(iconBitmap)
val icon = Icon.createWithBitmap(bitmap) val shortcut = ShortcutInfo.Builder(context, shortcutName)
.setShortLabel(shortcutName)
val shortcut = ShortcutInfo.Builder(context, game.title)
.setShortLabel(game.title)
.setIcon(icon) .setIcon(icon)
.setIntent(game.launchIntent.apply { .setIntent(game.launchIntent.apply {
putExtra("launched_from_shortcut", true) putExtra("launchedFromShortcut", true)
}) })
.build() .build()
shortcutManager.requestPinShortcut(shortcut, null)
shortcutManager?.requestPinShortcut(shortcut, null)
imagePath = null
} }
} }
.setNegativeButton(android.R.string.cancel) { _, _ ->
imagePath = null
}
.show()
bottomSheetDialog.dismiss()
}
bottomSheetView.findViewById<MaterialButton>(R.id.cheats).setOnClickListener { bottomSheetView.findViewById<MaterialButton>(R.id.cheats).setOnClickListener {
val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId) val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId)
@ -375,6 +436,47 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
bottomSheetDialog.show() 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 { private fun isValidGame(extension: String): Boolean {
return Game.badExtensions.stream() return Game.badExtensions.stream()
.noneMatch { extension == it.lowercase() } .noneMatch { extension == it.lowercase() }

View File

@ -5,6 +5,7 @@
package org.citra.citra_emu.fragments package org.citra.citra_emu.fragments
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
@ -12,7 +13,10 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding 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.model.Game
import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.GamesViewModel
import org.citra.citra_emu.viewmodel.HomeViewModel import org.citra.citra_emu.viewmodel.HomeViewModel
import androidx.core.content.edit
import androidx.core.text.HtmlCompat
class GamesFragment : Fragment() { class GamesFragment : Fragment() {
private var _binding: FragmentGamesBinding? = null private var _binding: FragmentGamesBinding? = null
@ -46,6 +48,13 @@ class GamesFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels()
private var show3DSFileWarning: Boolean = true 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -69,12 +78,18 @@ class GamesFragment : Fragment() {
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
gameAdapter = GameAdapter(
requireActivity() as AppCompatActivity,
inflater,
openImageLauncher
)
binding.gridGames.apply { binding.gridGames.apply {
layoutManager = GridLayoutManager( layoutManager = GridLayoutManager(
requireContext(), requireContext(),
resources.getInteger(R.integer.game_grid_columns) resources.getInteger(R.integer.game_grid_columns)
) )
adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) adapter = this@GamesFragment.gameAdapter
} }
binding.swipeRefresh.apply { binding.swipeRefresh.apply {

View File

@ -1,4 +1,4 @@
// Copyright 2023 Citra Emulator Project // Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version // Licensed under GPLv2 or any later version
// Refer to the license.txt file included. // 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 org.citra.citra_emu.viewmodel.HomeViewModel
import java.time.temporal.ChronoField import java.time.temporal.ChronoField
import java.util.Locale import java.util.Locale
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
class SearchFragment : Fragment() { class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null private var _binding: FragmentSearchBinding? = null
@ -43,6 +45,13 @@ class SearchFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel 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 private lateinit var preferences: SharedPreferences
@ -73,12 +82,18 @@ class SearchFragment : Fragment() {
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
gameAdapter = GameAdapter(
requireActivity() as AppCompatActivity,
inflater,
openImageLauncher
)
binding.gridGamesSearch.apply { binding.gridGamesSearch.apply {
layoutManager = GridLayoutManager( layoutManager = GridLayoutManager(
requireContext(), requireContext(),
resources.getInteger(R.integer.game_grid_columns) resources.getInteger(R.integer.game_grid_columns)
) )
adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) adapter = this@SearchFragment.gameAdapter
} }
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@android:color/black"/>
<corners android:radius="21dp"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/black"/>
<corners android:radius="21dp"/>
</shape>
</item>
</selector>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/shortcut_icon"
android:layout_width="96dp"
android:layout_height="96dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:background="?attr/selectableItemBackgroundBorderless"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Medium"/>
<ImageView
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_gravity="center"
android:src="@android:drawable/ic_menu_edit"
android:padding="8dp"
android:contentDescription="@string/edit_icon"
app:tint="?attr/colorAccent"
android:background="@drawable/shortcut_edit_background"
android:alpha="0.8"/>
</FrameLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/image_scale_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_gravity="center_horizontal"
android:enabled="false"
android:text="@string/shortcut_image_stretch_toggle"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/shortcut_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/shortcut_name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@ -541,7 +541,6 @@
<!-- About Game Dialog --> <!-- About Game Dialog -->
<string name="play">Play</string> <string name="play">Play</string>
<string name="shortcut">Shortcut</string>
<string name="uninstall_cia">Uninstall Application</string> <string name="uninstall_cia">Uninstall Application</string>
<string name="uninstalling">Uninstalling...</string> <string name="uninstalling">Uninstalling...</string>
<string name="game_context_open_save_dir">Open Save Data Folder</string> <string name="game_context_open_save_dir">Open Save Data Folder</string>
@ -553,6 +552,12 @@
<string name="game_context_open_extra">Open Extra Folder</string> <string name="game_context_open_extra">Open Extra Folder</string>
<string name="game_context_uninstall_dlc">Uninstall DLC</string> <string name="game_context_uninstall_dlc">Uninstall DLC</string>
<string name="game_context_uninstall_updates">Uninstall Updates</string> <string name="game_context_uninstall_updates">Uninstall Updates</string>
<string name="shortcut">Shortcut</string>
<string name="shortcut_name">Shortcut Name</string>
<string name="edit_icon">Edit icon</string>
<string name="create_shortcut">Create Shortcut</string>
<string name="shortcut_name_empty">Shortcut name cannot be empty</string>
<string name="shortcut_image_stretch_toggle">Stretch to fit image</string>
<!-- Performance Overlay settings --> <!-- Performance Overlay settings -->
<string name="performance_overlay_show">Show Performance Overlay</string> <string name="performance_overlay_show">Show Performance Overlay</string>