From 3e64dc0c8ccc3d87b6f851069e841422448fdc15 Mon Sep 17 00:00:00 2001 From: AzaharPlus Date: Mon, 31 Mar 2025 12:39:28 +0200 Subject: [PATCH] Restore features --- README.md | 10 + dist/apple/Info.plist.in | 1 + dist/org.azahar_emu.Azahar.xml | 1 + src/android/app/build.gradle.kts | 1 + .../java/org/citra/citra_emu/NativeLibrary.kt | 2 + .../DownloadSystemFilesDialogFragment.kt | 152 ++++++ .../citra_emu/fragments/GamesFragment.kt | 2 +- .../fragments/HomeSettingsFragment.kt | 18 +- .../fragments/SystemFilesFragment.kt | 391 +++++++------- .../java/org/citra/citra_emu/model/Game.kt | 2 +- .../viewmodel/SystemFilesViewModel.kt | 139 +++++ src/android/app/src/main/jni/native.cpp | 11 + .../layout-w600dp/fragment_system_files.xml | 219 ++++++++ .../main/res/layout/fragment_system_files.xml | 87 ++-- .../app/src/main/res/values/arrays.xml | 11 + .../app/src/main/res/values/strings.xml | 21 +- src/citra_qt/citra_qt.cpp | 29 +- src/citra_qt/configuration/configure_debug.ui | 74 +-- .../configuration/configure_system.cpp | 229 ++++---- src/citra_qt/configuration/configure_system.h | 3 + .../configuration/configure_system.ui | 488 +++++++----------- src/citra_qt/game_list.cpp | 2 +- src/citra_qt/game_list_p.h | 2 + src/citra_qt/uisettings.h | 2 +- src/common/common_paths.h | 1 + src/common/file_util.h | 16 + src/common/hacks/hack_list.cpp | 2 + src/core/file_sys/ncch_container.cpp | 212 ++++++++ src/core/file_sys/ncch_container.h | 8 + src/core/file_sys/romfs_reader.cpp | 10 + src/core/file_sys/romfs_reader.h | 16 +- src/core/file_sys/signature.h | 3 +- src/core/file_sys/ticket.cpp | 5 + src/core/file_sys/ticket.h | 3 +- src/core/hle/applets/erreula.cpp | 15 + src/core/hle/applets/erreula.h | 3 +- src/core/hle/kernel/ipc.cpp | 9 + src/core/hle/kernel/shared_page.h | 3 + src/core/hle/service/ac/ac.cpp | 16 + src/core/hle/service/ac/ac.h | 7 +- src/core/hle/service/act/act_errors.h | 2 + src/core/hle/service/am/am.cpp | 458 +++++++++++++--- src/core/hle/service/am/am.h | 49 +- src/core/hle/service/cfg/cfg.cpp | 132 +++++ src/core/hle/service/cfg/cfg.h | 55 ++ src/core/hw/aes/key.cpp | 185 ++++++- src/core/hw/ecc.cpp | 1 + src/core/hw/rsa/rsa.cpp | 91 ++++ src/core/hw/rsa/rsa.h | 3 + src/core/loader/artic.h | 17 + src/core/loader/loader.cpp | 8 +- 51 files changed, 2462 insertions(+), 765 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt create mode 100644 src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml diff --git a/README.md b/README.md index d2227b767..b68033674 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +AzaharPlus is a fork of the Azahar 3DS emulator that restores some features. + +Each version is the same as the corresponding version of Azahar exept for these features: +- Support of 3DS files. If a file works with earlier Citra forks, it works with AzaharPlus. +- Ability to download system files from official servers. No need for an actual 3DS. + +Below is the readme from Azahar, unchanged. + +--- + ![Azahar Emulator](https://azahar-emu.org/resources/images/logo/azahar-name-and-logo.svg) ![GitHub Release](https://img.shields.io/github/v/release/azahar-emu/azahar?label=Current%20Release) diff --git a/dist/apple/Info.plist.in b/dist/apple/Info.plist.in index d12a21f8e..22cfab071 100644 --- a/dist/apple/Info.plist.in +++ b/dist/apple/Info.plist.in @@ -31,6 +31,7 @@ CFBundleTypeExtensions + 3ds 3dsx cci cxi diff --git a/dist/org.azahar_emu.Azahar.xml b/dist/org.azahar_emu.Azahar.xml index 6f884c9ea..0fa7afbae 100644 --- a/dist/org.azahar_emu.Azahar.xml +++ b/dist/org.azahar_emu.Azahar.xml @@ -16,6 +16,7 @@ CTR Cart Image + diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 5a1ef0502..3c6399c66 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -63,6 +63,7 @@ android { // The application ID refers to Lime3DS to allow for // the Play Store listing, which was originally set up for Lime3DS, to still be used. applicationId = "io.github.lime3ds.android" + minSdk = 28 targetSdk = 35 versionCode = autoVersion diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt index 583e59863..5baa05b64 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt @@ -186,6 +186,8 @@ object NativeLibrary { external fun unlinkConsole() + external fun downloadTitleFromNus(title: Long): InstallStatus + private var coreErrorAlertResult = false private val coreErrorAlertLock = Object() diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt new file mode 100644 index 000000000..3f5abfd14 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt @@ -0,0 +1,152 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.citra.citra_emu.NativeLibrary.InstallStatus +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogProgressBarBinding +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.SystemFilesViewModel + +class DownloadSystemFilesDialogFragment : DialogFragment() { + private var _binding: DialogProgressBarBinding? = null + private val binding get() = _binding!! + + private val downloadViewModel: SystemFilesViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + private lateinit var titles: LongArray + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogProgressBarBinding.inflate(layoutInflater) + + titles = requireArguments().getLongArray(TITLES)!! + + binding.progressText.visibility = View.GONE + + binding.progressBar.min = 0 + binding.progressBar.max = titles.size + if (downloadViewModel.isDownloading.value != true) { + binding.progressBar.progress = 0 + } + + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.downloading_files) + .setMessage(R.string.downloading_files_description) + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + downloadViewModel.progress.collectLatest { binding.progressBar.progress = it } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + downloadViewModel.result.collect { + when (it) { + InstallStatus.Success -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance(R.string.download_success, 0) + .show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + gamesViewModel.setShouldSwapData(true) + } + + InstallStatus.ErrorFailedToOpenFile, + InstallStatus.ErrorEncrypted, + InstallStatus.ErrorFileNotFound, + InstallStatus.ErrorInvalid, + InstallStatus.ErrorAborted -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance( + R.string.download_failed, + R.string.download_failed_description + ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + gamesViewModel.setShouldSwapData(true) + } + + InstallStatus.Cancelled -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance( + R.string.download_cancelled, + R.string.download_cancelled_description + ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + } + + // Do nothing on null + else -> {} + } + } + } + } + } + + // Consider using WorkManager here. While the home menu can only really amount to + // about 150MBs, this could be a problem on inconsistent networks + downloadViewModel.download(titles) + } + + override fun onResume() { + super.onResume() + val alertDialog = dialog as AlertDialog + val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { + downloadViewModel.cancel() + dialog?.setTitle(R.string.cancelling) + binding.progressBar.isIndeterminate = true + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "DownloadSystemFilesDialogFragment" + + const val TITLES = "Titles" + + fun newInstance(titles: LongArray): DownloadSystemFilesDialogFragment { + val dialog = DownloadSystemFilesDialogFragment() + val args = Bundle() + args.putLongArray(TITLES, titles) + dialog.arguments = args + return dialog + } + } +} 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..2fcab82e6 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 @@ -45,7 +45,7 @@ class GamesFragment : Fragment() { private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() - private var show3DSFileWarning: Boolean = true + private var show3DSFileWarning: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt index 432a06aa0..e7ff2ba34 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt @@ -1,4 +1,4 @@ -// Copyright Citra Emulator Project / Azahar Emulator Project +// Copyright 2023 Citra Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -121,14 +121,8 @@ class HomeSettingsFragment : Fragment() { } ), HomeSetting( - R.string.install_game_content, - R.string.install_game_content_description, - R.drawable.ic_install, - { mainActivity.ciaFileInstaller.launch(true) } - ), - HomeSetting( - R.string.setup_system_files, - R.string.setup_system_files_description, + R.string.system_files, + R.string.system_files_description, R.drawable.ic_system_update, { exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) @@ -136,6 +130,12 @@ class HomeSettingsFragment : Fragment() { ?.navigate(R.id.action_homeSettingsFragment_to_systemFilesFragment) } ), + HomeSetting( + R.string.install_game_content, + R.string.install_game_content_description, + R.drawable.ic_install, + { mainActivity.ciaFileInstaller.launch(true) } + ), HomeSetting( R.string.share_log, R.string.share_log_description, diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt index 7b76149d3..4c79082f1 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt @@ -1,61 +1,76 @@ -// Copyright Citra Emulator Project / Azahar Emulator Project +// Copyright 2023 Citra Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. package org.citra.citra_emu.fragments -import android.content.DialogInterface +import android.content.res.Resources import android.os.Bundle +import android.text.Html import android.text.method.LinkMovementMethod -import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.AdapterView import android.widget.ArrayAdapter -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import androidx.core.widget.doOnTextChanged +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.progressindicator.CircularProgressIndicator -import com.google.android.material.textview.MaterialTextView +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.HomeNavigationDirections import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R -import org.citra.citra_emu.databinding.DialogSoftwareKeyboardBinding +import org.citra.citra_emu.activities.EmulationActivity import org.citra.citra_emu.databinding.FragmentSystemFilesBinding import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.SystemSaveGame import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel +import org.citra.citra_emu.viewmodel.SystemFilesViewModel class SystemFilesFragment : Fragment() { private var _binding: FragmentSystemFilesBinding? = null private val binding get() = _binding!! private val homeViewModel: HomeViewModel by activityViewModels() + private val systemFilesViewModel: SystemFilesViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels() + private lateinit var regionValues: IntArray + + private val systemTypeDropdown = DropdownItem(R.array.systemFileTypeValues) + private val systemRegionDropdown = DropdownItem(R.array.systemFileRegionValues) + + private val SYS_TYPE = "SysType" + private val REGION = "Region" private val REGION_START = "RegionStart" private val homeMenuMap: MutableMap = mutableMapOf() - private var setupStateCached: BooleanArray? = null - private lateinit var regionValues: IntArray + + private val WARNING_SHOWN = "SystemFilesWarningShown" + + private class DropdownItem(val valuesId: Int) : AdapterView.OnItemClickListener { + var position = 0 + + fun getValue(resources: Resources): Int { + return resources.getIntArray(valuesId)[position] + } + + override fun onItemClick(p0: AdapterView<*>?, view: View?, position: Int, id: Long) { + this.position = position + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -77,15 +92,61 @@ class SystemFilesFragment : Fragment() { homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) + val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + if (!preferences.getBoolean(WARNING_SHOWN, false)) { + MessageDialogFragment.newInstance( + R.string.home_menu_warning, + R.string.home_menu_warning_description + ).show(childFragmentManager, MessageDialogFragment.TAG) + preferences.edit() + .putBoolean(WARNING_SHOWN, true) + .apply() + } + + binding.toolbarSystemFiles.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + // TODO: Remove workaround for text filtering issue in material components when fixed // https://github.com/material-components/material-components-android/issues/1464 + binding.dropdownSystemType.isSaveEnabled = false + binding.dropdownSystemRegion.isSaveEnabled = false binding.dropdownSystemRegionStart.isSaveEnabled = false + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + systemFilesViewModel.shouldRefresh.collect { + if (it) { + reloadUi() + systemFilesViewModel.setShouldRefresh(false) + } + } + } + } + reloadUi() if (savedInstanceState != null) { + setDropdownSelection( + binding.dropdownSystemType, + systemTypeDropdown, + savedInstanceState.getInt(SYS_TYPE) + ) + setDropdownSelection( + binding.dropdownSystemRegion, + systemRegionDropdown, + savedInstanceState.getInt(REGION) + ) binding.dropdownSystemRegionStart .setText(savedInstanceState.getString(REGION_START), false) } + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(SYS_TYPE, systemTypeDropdown.position) + outState.putInt(REGION, systemRegionDropdown.position) + outState.putString(REGION_START, binding.dropdownSystemRegionStart.text.toString()) } override fun onResume() { @@ -98,41 +159,6 @@ class SystemFilesFragment : Fragment() { SystemSaveGame.save() } - private fun showProgressDialog( - main_title: CharSequence, - main_text: CharSequence - ): AlertDialog? { - val context = requireContext() - val progressIndicator = CircularProgressIndicator(context).apply { - isIndeterminate = true - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - Gravity.CENTER // Center the progress indicator - ).apply { - setMargins(50, 50, 50, 50) // Add margins (left, top, right, bottom) - } - } - - val pleaseWaitText = MaterialTextView(context).apply { - text = main_text - } - - val container = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setPadding(40, 40, 40, 40) // Optional: Add padding to the entire layout - addView(pleaseWaitText) - addView(progressIndicator) - } - - return MaterialAlertDialogBuilder(context) - .setTitle(main_title) - .setView(container) - .setCancelable(false) - .show() - } - private fun reloadUi() { val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) @@ -150,167 +176,31 @@ class SystemFilesFragment : Fragment() { gamesViewModel.setShouldSwapData(true) } - binding.setupSystemFilesDescription?.apply { - text = HtmlCompat.fromHtml( - context.getString(R.string.setup_system_files_preamble), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - movementMethod = LinkMovementMethod.getInstance() - } - - binding.buttonUnlinkConsoleData.isEnabled = NativeLibrary.isFullConsoleLinked() - binding.buttonUnlinkConsoleData.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.delete_system_files) - .setMessage(HtmlCompat.fromHtml( - requireContext().getString(R.string.delete_system_files_description), - HtmlCompat.FROM_HTML_MODE_COMPACT - )) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - NativeLibrary.unlinkConsole() - binding.buttonUnlinkConsoleData.isEnabled = NativeLibrary.isFullConsoleLinked() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - binding.buttonSetUpSystemFiles.setOnClickListener { - val inflater = LayoutInflater.from(context) - val inputBinding = DialogSoftwareKeyboardBinding.inflate(inflater) - var textInputValue: String = preferences.getString("last_artic_base_addr", "")!! - - val progressDialog = showProgressDialog( - getText(R.string.setup_system_files), - getString(R.string.setup_system_files_detect) - ) - - CoroutineScope(Dispatchers.IO).launch { - val setupState = setupStateCached ?: NativeLibrary.areSystemTitlesInstalled().also { - setupStateCached = it - } - - withContext(Dispatchers.Main) { - progressDialog?.dismiss() - - inputBinding.editTextInput.setText(textInputValue) - inputBinding.editTextInput.doOnTextChanged { text, _, _, _ -> - textInputValue = text.toString() - } - - val buttonGroup = context?.let { it1 -> RadioGroup(it1) }!! - - val buttonO3ds = context?.let { it1 -> - RadioButton(it1).apply { - text = context.getString(R.string.setup_system_files_o3ds) - isChecked = false - } - }!! - - val buttonN3ds = context?.let { it1 -> - RadioButton(it1).apply { - text = context.getString(R.string.setup_system_files_n3ds) - isChecked = false - } - }!! - - val textO3ds: String - val textN3ds: String - - val colorO3ds: Int - val colorN3ds: Int - - if (!setupStateCached!![0]) { - textO3ds = getString(R.string.setup_system_files_possible) - colorO3ds = R.color.citra_primary_blue - - textN3ds = getString(R.string.setup_system_files_o3ds_needed) - colorN3ds = R.color.citra_primary_yellow - - buttonN3ds.isEnabled = false - } else { - textO3ds = getString(R.string.setup_system_files_completed) - colorO3ds = R.color.citra_primary_green - - if (!setupStateCached!![1]) { - textN3ds = getString(R.string.setup_system_files_possible) - colorN3ds = R.color.citra_primary_blue - } else { - textN3ds = getString(R.string.setup_system_files_completed) - colorN3ds = R.color.citra_primary_green - } - } - - val tooltipO3ds = context?.let { it1 -> - MaterialTextView(it1).apply { - text = textO3ds - textSize = 12f - setTextColor(ContextCompat.getColor(requireContext(), colorO3ds)) - } - } - - val tooltipN3ds = context?.let { it1 -> - MaterialTextView(it1).apply { - text = textN3ds - textSize = 12f - setTextColor(ContextCompat.getColor(requireContext(), colorN3ds)) - } - } - - buttonGroup.apply { - addView(buttonO3ds) - addView(tooltipO3ds) - addView(buttonN3ds) - addView(tooltipN3ds) - } - - inputBinding.root.apply { - addView(buttonGroup) - } - - val dialog = context?.let { - MaterialAlertDialogBuilder(it) - .setView(inputBinding.root) - .setTitle(getString(R.string.setup_system_files_enter_address)) - .setPositiveButton(android.R.string.ok) { diag, _ -> - if (textInputValue.isNotEmpty() && !(!buttonO3ds.isChecked && !buttonN3ds.isChecked)) { - preferences.edit() - .putString("last_artic_base_addr", textInputValue) - .apply() - val menu = Game( - title = getString(R.string.artic_base), - path = if (buttonO3ds.isChecked) { - "articinio://$textInputValue" - } else { - "articinin://$textInputValue" - }, - filename = "" - ) - val progressDialog2 = showProgressDialog( - getText(R.string.setup_system_files), - getString( - R.string.setup_system_files_preparing - ) - ) - - CoroutineScope(Dispatchers.IO).launch { - NativeLibrary.uninstallSystemFiles(buttonO3ds.isChecked) - withContext(Dispatchers.Main) { - setupStateCached = null - progressDialog2?.dismiss() - val action = - HomeNavigationDirections.actionGlobalEmulationActivity( - menu - ) - binding.root.findNavController().navigate(action) - } - } - } - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - } + if (!NativeLibrary.areKeysAvailable()) { + binding.apply { + systemType.isEnabled = false + systemRegion.isEnabled = false + buttonDownloadHomeMenu.isEnabled = false + textKeysMissing.visibility = View.VISIBLE + textKeysMissingHelp.visibility = View.VISIBLE + textKeysMissingHelp.text = + Html.fromHtml(getString(R.string.how_to_get_keys), Html.FROM_HTML_MODE_LEGACY) + textKeysMissingHelp.movementMethod = LinkMovementMethod.getInstance() } + } else { + populateDownloadOptions() + } + + binding.buttonDownloadHomeMenu.setOnClickListener { + val titleIds = NativeLibrary.getSystemTitleIds( + systemTypeDropdown.getValue(resources), + systemRegionDropdown.getValue(resources) + ) + + DownloadSystemFilesDialogFragment.newInstance(titleIds).show( + childFragmentManager, + DownloadSystemFilesDialogFragment.TAG + ) } populateHomeMenuOptions() @@ -326,6 +216,51 @@ class SystemFilesFragment : Fragment() { } } + private fun populateDropdown( + dropdown: MaterialAutoCompleteTextView, + valuesId: Int, + dropdownItem: DropdownItem + ) { + val valuesAdapter = ArrayAdapter.createFromResource( + requireContext(), + valuesId, + R.layout.support_simple_spinner_dropdown_item + ) + dropdown.setAdapter(valuesAdapter) + dropdown.onItemClickListener = dropdownItem + } + + private fun setDropdownSelection( + dropdown: MaterialAutoCompleteTextView, + dropdownItem: DropdownItem, + selection: Int + ) { + if (dropdown.adapter != null) { + dropdown.setText(dropdown.adapter.getItem(selection).toString(), false) + } + dropdownItem.position = selection + } + + private fun populateDownloadOptions() { + populateDropdown(binding.dropdownSystemType, R.array.systemFileTypes, systemTypeDropdown) + populateDropdown( + binding.dropdownSystemRegion, + R.array.systemFileRegions, + systemRegionDropdown + ) + + setDropdownSelection( + binding.dropdownSystemType, + systemTypeDropdown, + systemTypeDropdown.position + ) + setDropdownSelection( + binding.dropdownSystemRegion, + systemRegionDropdown, + systemRegionDropdown.position + ) + } + private fun populateHomeMenuOptions() { regionValues = resources.getIntArray(R.array.systemFileRegionValues) val regionEntries = resources.getStringArray(R.array.systemFileRegions) @@ -350,4 +285,30 @@ class SystemFilesFragment : Fragment() { binding.dropdownSystemRegionStart.setText(availableMenus.keys.first(), false) } } + + 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 mlpAppBar = binding.toolbarSystemFiles.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarSystemFiles.layoutParams = mlpAppBar + + val mlpScrollSystemFiles = + binding.scrollSystemFiles.layoutParams as ViewGroup.MarginLayoutParams + mlpScrollSystemFiles.leftMargin = leftInsets + mlpScrollSystemFiles.rightMargin = rightInsets + binding.scrollSystemFiles.layoutParams = mlpScrollSystemFiles + + binding.scrollSystemFiles.updatePadding(bottom = barInsets.bottom) + + windowInsets + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt index 7fb6825dd..689b4ec25 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt @@ -63,7 +63,7 @@ class Game( val allExtensions: Set get() = extensions + badExtensions val extensions: Set = HashSet( - listOf("3dsx", "elf", "axf", "cci", "cxi", "app") + listOf("3ds", "3dsx", "elf", "axf", "cci", "cxi", "app") ) val badExtensions: Set = HashSet( diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt new file mode 100644 index 000000000..d4f654d5c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt @@ -0,0 +1,139 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.NativeLibrary.InstallStatus +import org.citra.citra_emu.utils.Log +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext +import kotlin.math.min + +class SystemFilesViewModel : ViewModel() { + private var job: Job + private val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + val isDownloading get() = _isDownloading.asStateFlow() + private val _isDownloading = MutableStateFlow(false) + + val progress get() = _progress.asStateFlow() + private val _progress = MutableStateFlow(0) + + val result get() = _result.asStateFlow() + private val _result = MutableStateFlow(null) + + val shouldRefresh get() = _shouldRefresh.asStateFlow() + private val _shouldRefresh = MutableStateFlow(false) + + private var cancelled = false + + private val RETRY_AMOUNT = 3 + + init { + job = Job() + clear() + } + + fun setShouldRefresh(refresh: Boolean) { + _shouldRefresh.value = refresh + } + + fun setProgress(progress: Int) { + _progress.value = progress + } + + fun download(titles: LongArray) { + if (isDownloading.value) { + return + } + clear() + _isDownloading.value = true + Log.debug("System menu download started.") + + val minExecutors = min(Runtime.getRuntime().availableProcessors(), titles.size) + val segment = (titles.size / minExecutors) + val atomicProgress = AtomicInteger(0) + for (i in 0 until minExecutors) { + val titlesSegment = if (i < minExecutors - 1) { + titles.copyOfRange(i * segment, (i + 1) * segment) + } else { + titles.copyOfRange(i * segment, titles.size) + } + + CoroutineScope(coroutineContext).launch { + titlesSegment.forEach { title: Long -> + // Notify UI of cancellation before ending coroutine + if (cancelled) { + _result.value = InstallStatus.ErrorAborted + cancelled = false + } + + // Takes a moment to see if the coroutine was cancelled + yield() + + // Retry downloading a title repeatedly + for (j in 0 until RETRY_AMOUNT) { + val result = tryDownloadTitle(title) + if (result == InstallStatus.Success) { + break + } else if (j == RETRY_AMOUNT - 1) { + _result.value = result + return@launch + } + Log.warning("Download for title{$title} failed, retrying in 3s...") + delay(3000L) + } + + Log.debug("Successfully installed title - $title") + setProgress(atomicProgress.incrementAndGet()) + + Log.debug("System File Progress - ${atomicProgress.get()} / ${titles.size}") + if (atomicProgress.get() == titles.size) { + _result.value = InstallStatus.Success + setShouldRefresh(true) + } + } + } + } + } + + private fun tryDownloadTitle(title: Long): InstallStatus { + val result = NativeLibrary.downloadTitleFromNus(title) + if (result != InstallStatus.Success) { + Log.error("Failed to install title $title with error - $result") + } + return result + } + + fun clear() { + Log.debug("Clearing") + job.cancelChildren() + job = Job() + _progress.value = 0 + _result.value = null + _isDownloading.value = false + cancelled = false + } + + fun cancel() { + Log.debug("Canceling system file download.") + cancelled = true + job.cancelChildren() + job = Job() + _progress.value = 0 + _result.value = InstallStatus.Cancelled + } +} diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 02cc329af..0b1d6f4dc 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -464,6 +464,17 @@ void Java_org_citra_citra_1emu_NativeLibrary_uninstallSystemFiles(JNIEnv* env, : Core::SystemTitleSet::New3ds); } +jobject Java_org_citra_citra_1emu_NativeLibrary_downloadTitleFromNus([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, + jlong title) { + [[maybe_unused]] const auto title_id = static_cast(title); + Service::AM::InstallStatus status = Service::AM::InstallFromNus(title_id); + if (status != Service::AM::InstallStatus::Success) { + return IDCache::GetJavaCiaInstallStatus(status); + } + return IDCache::GetJavaCiaInstallStatus(Service::AM::InstallStatus::Success); +} + [[maybe_unused]] static bool CheckKgslPresent() { constexpr auto KgslPath{"/dev/kgsl-3d0"}; diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml new file mode 100644 index 000000000..6c833f876 --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +