diff options
Diffstat (limited to 'src/android')
63 files changed, 1957 insertions, 708 deletions
diff --git a/src/android/.gitignore b/src/android/.gitignore index 121cc8484..ff7121acd 100644 --- a/src/android/.gitignore +++ b/src/android/.gitignore @@ -63,3 +63,6 @@ fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md + +# Autogenerated library for vulkan validation layers +libVkLayer_khronos_validation.so diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 431f899b3..ac43d84b7 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -27,7 +27,7 @@ android { namespace = "org.yuzu.yuzu_emu" compileSdkVersion = "android-34" - ndkVersion = "25.2.9519653" + ndkVersion = "26.1.10909125" buildFeatures { viewBinding = true @@ -203,23 +203,23 @@ ktlint { } dependencies { - implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.recyclerview:recyclerview:1.3.0") + implementation("androidx.recyclerview:recyclerview:1.3.1") implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.fragment:fragment-ktx:1.6.0") + implementation("androidx.fragment:fragment-ktx:1.6.1") implementation("androidx.documentfile:documentfile:1.0.1") implementation("com.google.android.material:material:1.9.0") - implementation("androidx.preference:preference:1.2.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") implementation("io.coil-kt:coil:2.2.2") implementation("androidx.core:core-splashscreen:1.0.1") - implementation("androidx.window:window:1.1.0") + implementation("androidx.window:window:1.2.0-beta03") implementation("org.ini4j:ini4j:0.5.4") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation("androidx.navigation:navigation-fragment-ktx:2.6.0") - implementation("androidx.navigation:navigation-ui-ktx:2.6.0") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.4") + implementation("androidx.navigation:navigation-ui-ktx:2.7.4") implementation("info.debatty:java-string-similarity:2.0.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") } diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 832c08e15..a67351727 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,6 @@ SPDX-License-Identifier: GPL-3.0-or-later android:appCategory="game" android:localeConfig="@xml/locales_config" android:banner="@drawable/tv_banner" - android:extractNativeLibs="true" android:fullBackupContent="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules_api_31" android:enableOnBackInvokedCallback="true"> diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 21f67f32a..115f72710 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -15,13 +15,9 @@ import androidx.annotation.Keep import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.lang.ref.WeakReference -import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath -import org.yuzu.yuzu_emu.utils.FileUtil.exists -import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize -import org.yuzu.yuzu_emu.utils.FileUtil.isDirectory -import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri +import org.yuzu.yuzu_emu.utils.FileUtil import org.yuzu.yuzu_emu.utils.Log import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable @@ -75,7 +71,7 @@ object NativeLibrary { return if (isNativePath(path!!)) { YuzuApplication.documentsTree!!.openContentUri(path, openmode) } else { - openContentUri(appContext, path, openmode) + FileUtil.openContentUri(path, openmode) } } @@ -85,7 +81,7 @@ object NativeLibrary { return if (isNativePath(path!!)) { YuzuApplication.documentsTree!!.getFileSize(path) } else { - getFileSize(appContext, path) + FileUtil.getFileSize(path) } } @@ -95,7 +91,7 @@ object NativeLibrary { return if (isNativePath(path!!)) { YuzuApplication.documentsTree!!.exists(path) } else { - exists(appContext, path) + FileUtil.exists(path) } } @@ -105,7 +101,7 @@ object NativeLibrary { return if (isNativePath(path!!)) { YuzuApplication.documentsTree!!.isDirectory(path) } else { - isDirectory(appContext, path) + FileUtil.isDirectory(path) } } @@ -247,7 +243,12 @@ object NativeLibrary { external fun setAppDirectory(directory: String) - external fun installFileToNand(filename: String): Int + /** + * Installs a nsp or xci file to nand + * @param filename String representation of file uri + * @param extension Lowercase string representation of file extension without "." + */ + external fun installFileToNand(filename: String, extension: String): Int external fun initializeGpuDriver( hookLibDir: String?, @@ -512,6 +513,11 @@ object NativeLibrary { external fun submitInlineKeyboardInput(key_code: Int) /** + * Creates a generic user directory if it doesn't exist already + */ + external fun initializeEmptyUserDirectory() + + /** * Button type for use in onTouchEvent */ object ButtonType { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index 9561748cb..8c053670c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -47,7 +47,7 @@ class YuzuApplication : Application() { application = this documentsTree = DocumentsTree() DirectoryInitialization.start() - GpuDriverHelper.initializeDriverParameters(applicationContext) + GpuDriverHelper.initializeDriverParameters() NativeLibrary.logDeviceInfo() createNotificationChannels() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index d4ae39661..e96a2059b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -3,6 +3,7 @@ package org.yuzu.yuzu_emu.activities +import android.annotation.SuppressLint import android.app.Activity import android.app.PendingIntent import android.app.PictureInPictureParams @@ -397,6 +398,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { } } + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration @@ -409,7 +411,11 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { addAction(actionMute) addAction(actionUnmute) }.also { - registerReceiver(pictureInPictureReceiver, it) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED) + } else { + registerReceiver(pictureInPictureReceiver, it) + } } } else { try { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt new file mode 100644 index 000000000..0e818cab9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.CardDriverOptionBinding +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.GpuDriverMetadata + +class DriverAdapter(private val driverViewModel: DriverViewModel) : + ListAdapter<Pair<String, GpuDriverMetadata>, DriverAdapter.DriverViewHolder>( + AsyncDifferConfig.Builder(DiffCallback()).build() + ) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder { + val binding = + CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return DriverViewHolder(binding) + } + + override fun getItemCount(): Int = currentList.size + + override fun onBindViewHolder(holder: DriverViewHolder, position: Int) = + holder.bind(currentList[position]) + + private fun onSelectDriver(position: Int) { + driverViewModel.setSelectedDriverIndex(position) + notifyItemChanged(driverViewModel.previouslySelectedDriver) + notifyItemChanged(driverViewModel.selectedDriver) + } + + private fun onDeleteDriver(driverData: Pair<String, GpuDriverMetadata>, position: Int) { + if (driverViewModel.selectedDriver > position) { + driverViewModel.setSelectedDriverIndex(driverViewModel.selectedDriver - 1) + } + if (GpuDriverHelper.customDriverData == driverData.second) { + driverViewModel.setSelectedDriverIndex(0) + } + driverViewModel.driversToDelete.add(driverData.first) + driverViewModel.removeDriver(driverData) + notifyItemRemoved(position) + notifyItemChanged(driverViewModel.selectedDriver) + } + + inner class DriverViewHolder(val binding: CardDriverOptionBinding) : + RecyclerView.ViewHolder(binding.root) { + private lateinit var driverData: Pair<String, GpuDriverMetadata> + + fun bind(driverData: Pair<String, GpuDriverMetadata>) { + this.driverData = driverData + val driver = driverData.second + + binding.apply { + radioButton.isChecked = driverViewModel.selectedDriver == bindingAdapterPosition + root.setOnClickListener { + onSelectDriver(bindingAdapterPosition) + } + buttonDelete.setOnClickListener { + onDeleteDriver(driverData, bindingAdapterPosition) + } + + // Delay marquee by 3s + title.postDelayed( + { + title.isSelected = true + title.ellipsize = TextUtils.TruncateAt.MARQUEE + version.isSelected = true + version.ellipsize = TextUtils.TruncateAt.MARQUEE + description.isSelected = true + description.ellipsize = TextUtils.TruncateAt.MARQUEE + }, + 3000 + ) + if (driver.name == null) { + title.setText(R.string.system_gpu_driver) + description.text = "" + version.text = "" + version.visibility = View.GONE + description.visibility = View.GONE + buttonDelete.visibility = View.GONE + } else { + title.text = driver.name + version.text = driver.version + description.text = driver.description + version.visibility = View.VISIBLE + description.visibility = View.VISIBLE + buttonDelete.visibility = View.VISIBLE + } + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback<Pair<String, GpuDriverMetadata>>() { + override fun areItemsTheSame( + oldItem: Pair<String, GpuDriverMetadata>, + newItem: Pair<String, GpuDriverMetadata> + ): Boolean { + return oldItem.first == newItem.first + } + + override fun areContentsTheSame( + oldItem: Pair<String, GpuDriverMetadata>, + newItem: Pair<String, GpuDriverMetadata> + ): Boolean { + return oldItem.second == newItem.second + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt index 1675627a1..58ce343f4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt @@ -49,6 +49,7 @@ class HomeSettingAdapter( holder.option.onClick.invoke() } else { MessageDialogFragment.newInstance( + activity, titleId = holder.option.disabledTitleId, descriptionId = holder.option.disabledMessageId ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt new file mode 100644 index 000000000..e960fbaab --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.CardInstallableBinding +import org.yuzu.yuzu_emu.model.Installable + +class InstallableAdapter(private val installables: List<Installable>) : + RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): InstallableAdapter.InstallableViewHolder { + val binding = + CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return InstallableViewHolder(binding) + } + + override fun getItemCount(): Int = installables.size + + override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) = + holder.bind(installables[position]) + + inner class InstallableViewHolder(val binding: CardInstallableBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var installable: Installable + + fun bind(installable: Installable) { + this.installable = installable + + binding.title.setText(installable.titleId) + binding.description.setText(installable.descriptionId) + + if (installable.install != null) { + binding.buttonInstall.visibility = View.VISIBLE + binding.buttonInstall.setOnClickListener { installable.install.invoke() } + } + if (installable.export != null) { + binding.buttonExport.visibility = View.VISIBLE + binding.buttonExport.setOnClickListener { installable.export.invoke() } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt index 4d2f2f604..c73edd50e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -21,6 +21,7 @@ import androidx.navigation.navArgs import com.google.android.material.color.MaterialColors import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary import java.io.IOException import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding @@ -168,7 +169,7 @@ class SettingsActivity : AppCompatActivity() { if (!settingsFile.delete()) { throw IOException("Failed to delete $settingsFile") } - Settings.settingsList.forEach { it.reset() } + NativeLibrary.reloadSettings() Toast.makeText( applicationContext, @@ -181,12 +182,14 @@ class SettingsActivity : AppCompatActivity() { private fun setInsets() { ViewCompat.setOnApplyWindowInsetsListener( binding.navigationBarShade - ) { view: View, windowInsets: WindowInsetsCompat -> + ) { _: View, windowInsets: WindowInsetsCompat -> val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val mlpShade = view.layoutParams as MarginLayoutParams - mlpShade.height = barInsets.bottom - view.layoutParams = mlpShade + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade windowInsets } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt new file mode 100644 index 000000000..df21d74b2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.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.activity.result.contract.ActivityResultContracts +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.lifecycleScope +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.DriverAdapter +import org.yuzu.yuzu_emu.databinding.FragmentDriverManagerBinding +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import java.io.File +import java.io.IOException + +class DriverManagerFragment : Fragment() { + private var _binding: FragmentDriverManagerBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + 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 = FragmentDriverManagerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + if (!driverViewModel.isInteractionAllowed) { + DriversLoadingDialogFragment().show( + childFragmentManager, + DriversLoadingDialogFragment.TAG + ) + } + + binding.toolbarDrivers.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.buttonInstall.setOnClickListener { + getDriver.launch(arrayOf("application/zip")) + } + + binding.listDrivers.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = DriverAdapter(driverViewModel) + } + + viewLifecycleOwner.lifecycleScope.apply { + launch { + driverViewModel.driverList.collectLatest { + (binding.listDrivers.adapter as DriverAdapter).submitList(it) + } + } + launch { + driverViewModel.newDriverInstalled.collect { + if (_binding != null && it) { + (binding.listDrivers.adapter as DriverAdapter).apply { + notifyItemChanged(driverViewModel.previouslySelectedDriver) + notifyItemChanged(driverViewModel.selectedDriver) + driverViewModel.setNewDriverInstalled(false) + } + } + } + } + } + + setInsets() + } + + // Start installing requested driver + override fun onStop() { + super.onStop() + driverViewModel.onCloseDriverManager() + } + + 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.toolbarDrivers.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarDrivers.layoutParams = mlpAppBar + + val mlplistDrivers = binding.listDrivers.layoutParams as ViewGroup.MarginLayoutParams + mlplistDrivers.leftMargin = leftInsets + mlplistDrivers.rightMargin = rightInsets + binding.listDrivers.layoutParams = mlplistDrivers + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + val mlpFab = + binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams + mlpFab.leftMargin = leftInsets + fabSpacing + mlpFab.rightMargin = rightInsets + fabSpacing + mlpFab.bottomMargin = barInsets.bottom + fabSpacing + binding.buttonInstall.layoutParams = mlpFab + + binding.listDrivers.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val getDriver = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_driver, + false + ) { + val driverPath = + "${GpuDriverHelper.driverStoragePath}/${FileUtil.getFilename(result)}" + val driverFile = File(driverPath) + + // Ignore file exceptions when a user selects an invalid zip + try { + if (!GpuDriverHelper.copyDriverToInternalStorage(result)) { + throw IOException("Driver failed validation!") + } + } catch (_: IOException) { + if (driverFile.exists()) { + driverFile.delete() + } + return@newInstance getString(R.string.select_gpu_driver_error) + } + + val driverData = GpuDriverHelper.getMetadataFromZip(driverFile) + val driverInList = + driverViewModel.driverList.value.firstOrNull { it.second == driverData } + if (driverInList != null) { + return@newInstance getString(R.string.driver_already_installed) + } else { + driverViewModel.addDriver(Pair(driverPath, driverData)) + driverViewModel.setNewDriverInstalled(true) + } + return@newInstance Any() + }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 000000000..f8c34346a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.model.DriverViewModel + +class DriversLoadingDialogFragment : DialogFragment() { + private val driverViewModel: DriverViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + + isCancelable = false + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.loading) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.areDriversLoading.collect { checkForDismiss() } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.isDriverReady.collect { checkForDismiss() } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.isDeletingDrivers.collect { checkForDismiss() } + } + } + } + } + + private fun checkForDismiss() { + if (driverViewModel.isInteractionAllowed) { + dismiss() + } + } + + companion object { + const val TAG = "DriversLoadingDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 3e6c157c7..598a9d42b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -15,9 +15,9 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.Rational import android.view.* import android.widget.TextView +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.PopupMenu import androidx.core.content.res.ResourcesCompat @@ -39,6 +39,7 @@ import androidx.window.layout.WindowLayoutInfo import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.Slider import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.HomeNavigationDirections @@ -50,10 +51,12 @@ import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.EmulationViewModel import org.yuzu.yuzu_emu.overlay.InputOverlay import org.yuzu.yuzu_emu.utils.* +import java.lang.NullPointerException class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var preferences: SharedPreferences @@ -69,6 +72,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var game: Game private val emulationViewModel: EmulationViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() private var isInFoldableLayout = false @@ -105,10 +109,21 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { null } } - game = if (args.game != null) { - args.game!! - } else { - intentGame ?: error("[EmulationFragment] No bootable game present!") + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return } // So this fragment doesn't restart on configuration changes; i.e. rotation. @@ -132,6 +147,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // This is using the correct scope, lint is just acting up @SuppressLint("UnsafeRepeatOnLifecycleDetector") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + binding.surfaceEmulation.holder.addCallback(this) binding.showFpsText.setTextColor(Color.YELLOW) binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } @@ -282,29 +302,43 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.isDriverReady.collect { + if (it && !emulationState.isRunning) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated) + } + } + } + } } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + + updateScreenLayout() if (emulationActivity?.isInPictureInPictureMode == true) { if (binding.drawerLayout.isOpen) { binding.drawerLayout.close() } if (EmulationMenuSettings.showOverlay) { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.VISIBLE - } + binding.surfaceInputOverlay.visibility = View.INVISIBLE } } else { if (EmulationMenuSettings.showOverlay && emulationViewModel.emulationStarted.value) { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.VISIBLE - } + binding.surfaceInputOverlay.visibility = View.VISIBLE } else { - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.visibility = View.INVISIBLE - } + binding.surfaceInputOverlay.visibility = View.INVISIBLE } if (!isInFoldableLayout) { if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { @@ -316,19 +350,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } - override fun onResume() { - super.onResume() - if (!DirectoryInitialization.areDirectoriesReady) { - DirectoryInitialization.start() - } - - updateScreenLayout() - - emulationState.run(emulationActivity!!.isActivityRecreated) - } - override fun onPause() { - if (emulationState.isRunning) { + if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { emulationState.pause() } super.onPause() @@ -394,16 +417,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } private fun updateScreenLayout() { - binding.surfaceEmulation.setAspectRatio( - when (IntSetting.RENDERER_ASPECT_RATIO.int) { - 0 -> Rational(16, 9) - 1 -> Rational(4, 3) - 2 -> Rational(21, 9) - 3 -> Rational(16, 10) - 4 -> null // Stretch - else -> Rational(16, 9) - } - ) + binding.surfaceEmulation.setAspectRatio(null) emulationActivity?.buildPictureInPictureParams() updateOrientation() } @@ -693,7 +707,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private class EmulationState(private val gamePath: String) { private var state: State private var surface: Surface? = null - private var runWhenSurfaceIsValid = false init { // Starting state is stopped. @@ -751,8 +764,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // If the surface is set, run now. Otherwise, wait for it to get set. if (surface != null) { runWithValidSurface() - } else { - runWhenSurfaceIsValid = true } } @@ -760,7 +771,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { @Synchronized fun newSurface(surface: Surface?) { this.surface = surface - if (runWhenSurfaceIsValid) { + if (this.surface != null) { runWithValidSurface() } } @@ -788,10 +799,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } private fun runWithValidSurface() { - runWhenSurfaceIsValid = false + NativeLibrary.surfaceChanged(surface) when (state) { State.STOPPED -> { - NativeLibrary.surfaceChanged(surface) val emulationThread = Thread({ Log.debug("[EmulationFragment] Starting emulation thread.") NativeLibrary.run(gamePath) @@ -801,7 +811,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { State.PAUSED -> { Log.debug("[EmulationFragment] Resuming emulation.") - NativeLibrary.surfaceChanged(surface) NativeLibrary.unpauseEmulation() } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index c119e69c9..fd9785075 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -5,7 +5,6 @@ package org.yuzu.yuzu_emu.fragments import android.Manifest import android.content.ActivityNotFoundException -import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle @@ -28,7 +27,6 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController 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.BuildConfig import org.yuzu.yuzu_emu.HomeNavigationDirections @@ -37,6 +35,7 @@ import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.HomeSetting import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.ui.main.MainActivity @@ -50,6 +49,7 @@ class HomeSettingsFragment : Fragment() { private lateinit var mainActivity: MainActivity private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -107,29 +107,28 @@ class HomeSettingsFragment : Fragment() { ) add( HomeSetting( - R.string.install_gpu_driver, + R.string.gpu_driver_manager, R.string.install_gpu_driver_description, - R.drawable.ic_exit, - { driverInstaller() }, + R.drawable.ic_build, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_driverManagerFragment) + }, { GpuDriverHelper.supportsCustomDriverLoading() }, R.string.custom_driver_not_supported, - R.string.custom_driver_not_supported_description - ) - ) - add( - HomeSetting( - R.string.install_amiibo_keys, - R.string.install_amiibo_keys_description, - R.drawable.ic_nfc, - { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } + R.string.custom_driver_not_supported_description, + driverViewModel.selectedDriverMetadata ) ) add( HomeSetting( - R.string.install_game_content, - R.string.install_game_content_description, - R.drawable.ic_system_update_alt, - { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } + R.string.manage_yuzu_data, + R.string.manage_yuzu_data_description, + R.drawable.ic_install, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_installableFragment) + } ) ) add( @@ -150,35 +149,6 @@ class HomeSettingsFragment : Fragment() { ) add( HomeSetting( - R.string.manage_save_data, - R.string.import_export_saves_description, - R.drawable.ic_save, - { - ImportExportSavesFragment().show( - parentFragmentManager, - ImportExportSavesFragment.TAG - ) - } - ) - ) - add( - HomeSetting( - R.string.install_prod_keys, - R.string.install_prod_keys_description, - R.drawable.ic_unlock, - { mainActivity.getProdKey.launch(arrayOf("*/*")) } - ) - ) - add( - HomeSetting( - R.string.install_firmware, - R.string.install_firmware_description, - R.drawable.ic_firmware, - { mainActivity.getFirmware.launch(arrayOf("application/zip")) } - ) - ) - add( - HomeSetting( R.string.share_log, R.string.share_log_description, R.drawable.ic_log, @@ -326,31 +296,6 @@ class HomeSettingsFragment : Fragment() { } } - private fun driverInstaller() { - // Get the driver name for the dialog message. - var driverName = GpuDriverHelper.customDriverName - if (driverName == null) { - driverName = getString(R.string.system_gpu_driver) - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.select_gpu_driver_title)) - .setMessage(driverName) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int -> - GpuDriverHelper.installDefaultDriver(requireContext()) - Toast.makeText( - requireContext(), - R.string.select_gpu_driver_use_default, - Toast.LENGTH_SHORT - ).show() - } - .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int -> - mainActivity.getDriver.launch(arrayOf("application/zip")) - } - .show() - } - private fun shareLog() { val file = DocumentFile.fromSingleUri( mainActivity, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt deleted file mode 100644 index f38aeea53..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.provider.DocumentsContract -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.io.BufferedOutputStream -import java.io.File -import java.io.FileOutputStream -import java.io.FilenameFilter -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.DocumentProvider -import org.yuzu.yuzu_emu.getPublicFilesDir -import org.yuzu.yuzu_emu.utils.FileUtil - -class ImportExportSavesFragment : DialogFragment() { - private val context = YuzuApplication.appContext - private val savesFolder = - "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" - - // Get first subfolder in saves folder (should be the user folder) - private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" - private var lastZipCreated: File? = null - - private lateinit var startForResultExportSave: ActivityResultLauncher<Intent> - private lateinit var documentPicker: ActivityResultLauncher<Array<String>> - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val activity = requireActivity() as AppCompatActivity - - val activityResultRegistry = requireActivity().activityResultRegistry - startForResultExportSave = activityResultRegistry.register( - "startForResultExportSaveKey", - ActivityResultContracts.StartActivityForResult() - ) { - File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively() - } - documentPicker = activityResultRegistry.register( - "documentPickerKey", - ActivityResultContracts.OpenDocument() - ) { - it?.let { uri -> importSave(uri, activity) } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return if (savesFolderRoot == "") { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.manage_save_data) - .setMessage(R.string.import_export_saves_no_profile) - .setPositiveButton(android.R.string.ok, null) - .show() - } else { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.manage_save_data) - .setMessage(R.string.manage_save_data_description) - .setNegativeButton(R.string.export_saves) { _, _ -> - exportSave() - } - .setPositiveButton(R.string.import_saves) { _, _ -> - documentPicker.launch(arrayOf("application/zip")) - } - .setNeutralButton(android.R.string.cancel, null) - .show() - } - } - - /** - * Zips the save files located in the given folder path and creates a new zip file with the current date and time. - * @return true if the zip file is successfully created, false otherwise. - */ - private fun zipSave(): Boolean { - try { - val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp") - tempFolder.mkdirs() - val saveFolder = File(savesFolderRoot) - val outputZipFile = File( - tempFolder, - "yuzu saves - ${ - LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) - }.zip" - ) - outputZipFile.createNewFile() - ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos -> - saveFolder.walkTopDown().forEach { file -> - val zipFileName = - file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/") - if (zipFileName == "") { - return@forEach - } - val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") - zos.putNextEntry(entry) - if (file.isFile) { - file.inputStream().use { fis -> fis.copyTo(zos) } - } - } - } - lastZipCreated = outputZipFile - } catch (e: Exception) { - return false - } - return true - } - - /** - * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. - */ - private fun exportSave() { - CoroutineScope(Dispatchers.IO).launch { - val wasZipCreated = zipSave() - val lastZipFile = lastZipCreated - if (!wasZipCreated || lastZipFile == null) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show() - } - return@launch - } - - withContext(Dispatchers.Main) { - val file = DocumentFile.fromSingleUri( - context, - DocumentsContract.buildDocumentUri( - DocumentProvider.AUTHORITY, - "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}" - ) - )!! - val intent = Intent(Intent.ACTION_SEND) - .setDataAndType(file.uri, "application/zip") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .putExtra(Intent.EXTRA_STREAM, file.uri) - startForResultExportSave.launch(Intent.createChooser(intent, "Share save file")) - } - } - } - - /** - * Imports the save files contained in the zip file, and replaces any existing ones with the new save file. - * @param zipUri The Uri of the zip file containing the save file(s) to import. - */ - private fun importSave(zipUri: Uri, activity: AppCompatActivity) { - val inputZip = context.contentResolver.openInputStream(zipUri) - // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid. - var validZip = false - val savesFolder = File(savesFolderRoot) - val cacheSaveDir = File("${context.cacheDir.path}/saves/") - cacheSaveDir.mkdir() - - if (inputZip == null) { - Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) - .show() - return - } - - val filterTitleId = - FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } - - try { - CoroutineScope(Dispatchers.IO).launch { - FileUtil.unzip(inputZip, cacheSaveDir) - cacheSaveDir.list(filterTitleId)?.forEach { savePath -> - File(savesFolder, savePath).deleteRecursively() - File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true) - validZip = true - } - - withContext(Dispatchers.Main) { - if (!validZip) { - MessageDialogFragment.newInstance( - titleId = R.string.save_file_invalid_zip_structure, - descriptionId = R.string.save_file_invalid_zip_structure_description - ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) - return@withContext - } - Toast.makeText( - context, - context.getString(R.string.save_file_imported_success), - Toast.LENGTH_LONG - ).show() - } - - cacheSaveDir.deleteRecursively() - } - } catch (e: Exception) { - Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) - .show() - } - } - - companion object { - const val TAG = "ImportExportSavesFragment" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt index 18bc34b9f..7e467814d 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt @@ -9,8 +9,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -18,6 +19,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding import org.yuzu.yuzu_emu.model.TaskViewModel @@ -28,19 +30,25 @@ class IndeterminateProgressDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val titleId = requireArguments().getInt(TITLE) + val cancellable = requireArguments().getBoolean(CANCELLABLE) binding = DialogProgressBarBinding.inflate(layoutInflater) binding.progressBar.isIndeterminate = true val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle(titleId) .setView(binding.root) - .create() - dialog.setCanceledOnTouchOutside(false) + + if (cancellable) { + dialog.setNegativeButton(android.R.string.cancel, null) + } + + val alertDialog = dialog.create() + alertDialog.setCanceledOnTouchOutside(false) if (!taskViewModel.isRunning.value) { taskViewModel.runTask() } - return dialog + return alertDialog } override fun onCreateView( @@ -53,41 +61,74 @@ class IndeterminateProgressDialogFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - taskViewModel.isComplete.collect { - if (it) { - dismiss() - when (val result = taskViewModel.result.value) { - is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG) - .show() - - is MessageDialogFragment -> result.show( - requireActivity().supportFragmentManager, - MessageDialogFragment.TAG - ) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.isComplete.collect { + if (it) { + dismiss() + when (val result = taskViewModel.result.value) { + is String -> Toast.makeText( + requireContext(), + result, + Toast.LENGTH_LONG + ).show() + + is MessageDialogFragment -> result.show( + requireActivity().supportFragmentManager, + MessageDialogFragment.TAG + ) + + else -> { + // Do nothing + } + } + taskViewModel.clear() + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.cancelled.collect { + if (it) { + dialog?.setTitle(R.string.cancelling) } - taskViewModel.clear() } } } } } + // By default, the ProgressDialog will immediately dismiss itself upon a button being pressed. + // Setting the OnClickListener again after the dialog is shown overrides this behavior. + override fun onResume() { + super.onResume() + val alertDialog = dialog as AlertDialog + val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { + alertDialog.setTitle(getString(R.string.cancelling)) + taskViewModel.setCancelled(true) + } + } + companion object { const val TAG = "IndeterminateProgressDialogFragment" private const val TITLE = "Title" + private const val CANCELLABLE = "Cancellable" fun newInstance( - activity: AppCompatActivity, + activity: FragmentActivity, titleId: Int, + cancellable: Boolean = false, task: () -> Any ): IndeterminateProgressDialogFragment { val dialog = IndeterminateProgressDialogFragment() val args = Bundle() ViewModelProvider(activity)[TaskViewModel::class.java].task = task args.putInt(TITLE, titleId) + args.putBoolean(CANCELLABLE, cancellable) dialog.arguments = args return dialog } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt new file mode 100644 index 000000000..ec116ab62 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.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.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.InstallableAdapter +import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.Installable +import org.yuzu.yuzu_emu.ui.main.MainActivity + +class InstallableFragment : Fragment() { + private var _binding: FragmentInstallablesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + 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 = FragmentInstallablesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarInstallables.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val installables = listOf( + Installable( + R.string.user_data, + R.string.user_data_description, + install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, + export = { mainActivity.exportUserData.launch("export.zip") } + ), + Installable( + R.string.install_game_content, + R.string.install_game_content_description, + install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_firmware, + R.string.install_firmware_description, + install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) } + ), + if (mainActivity.savesFolderRoot != "") { + Installable( + R.string.manage_save_data, + R.string.import_export_saves_description, + install = { mainActivity.importSaves.launch(arrayOf("application/zip")) }, + export = { mainActivity.exportSave() } + ) + } else { + Installable( + R.string.manage_save_data, + R.string.import_export_saves_description, + install = { mainActivity.importSaves.launch(arrayOf("application/zip")) } + ) + }, + Installable( + R.string.install_prod_keys, + R.string.install_prod_keys_description, + install = { mainActivity.getProdKey.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_amiibo_keys, + R.string.install_amiibo_keys_description, + install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } + ) + ) + + binding.listInstallables.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = InstallableAdapter(installables) + } + + setInsets() + } + + 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.toolbarInstallables.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarInstallables.layoutParams = mlpAppBar + + val mlpScrollAbout = + binding.listInstallables.layoutParams as ViewGroup.MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.listInstallables.layoutParams = mlpScrollAbout + + binding.listInstallables.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt index 7d1c2c8dd..541b22f47 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt @@ -4,14 +4,21 @@ package org.yuzu.yuzu_emu.fragments import android.app.Dialog +import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.MessageDialogViewModel class MessageDialogFragment : DialogFragment() { + private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val titleId = requireArguments().getInt(TITLE_ID) val titleString = requireArguments().getString(TITLE_STRING)!! @@ -37,6 +44,12 @@ class MessageDialogFragment : DialogFragment() { return dialog.show() } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + messageDialogViewModel.dismissAction.invoke() + messageDialogViewModel.clear() + } + private fun openLink(link: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) startActivity(intent) @@ -52,11 +65,13 @@ class MessageDialogFragment : DialogFragment() { private const val HELP_LINK = "Link" fun newInstance( + activity: FragmentActivity, titleId: Int = 0, titleString: String = "", descriptionId: Int = 0, descriptionString: String = "", - helpLinkId: Int = 0 + helpLinkId: Int = 0, + dismissAction: () -> Unit = {} ): MessageDialogFragment { val dialog = MessageDialogFragment() val bundle = Bundle() @@ -67,6 +82,8 @@ class MessageDialogFragment : DialogFragment() { putString(DESCRIPTION_STRING, descriptionString) putInt(HELP_LINK, helpLinkId) } + ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction = + dismissAction dialog.arguments = bundle return dialog } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt index fbb2f6e18..c66bb635a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -295,8 +295,10 @@ class SetupFragment : Fragment() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) - outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + if (_binding != null) { + outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) + outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + } outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) } @@ -353,11 +355,15 @@ class SetupFragment : Fragment() { } fun pageForward() { - binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 + if (_binding != null) { + binding.viewPager2.currentItem += 1 + } } fun pageBackward() { - binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 + if (_binding != null) { + binding.viewPager2.currentItem -= 1 + } } fun setPageWarned(page: Int) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt new file mode 100644 index 000000000..62945ad65 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.GpuDriverMetadata +import java.io.BufferedOutputStream +import java.io.File + +class DriverViewModel : ViewModel() { + private val _areDriversLoading = MutableStateFlow(false) + val areDriversLoading: StateFlow<Boolean> get() = _areDriversLoading + + private val _isDriverReady = MutableStateFlow(true) + val isDriverReady: StateFlow<Boolean> get() = _isDriverReady + + private val _isDeletingDrivers = MutableStateFlow(false) + val isDeletingDrivers: StateFlow<Boolean> get() = _isDeletingDrivers + + private val _driverList = MutableStateFlow(mutableListOf<Pair<String, GpuDriverMetadata>>()) + val driverList: StateFlow<MutableList<Pair<String, GpuDriverMetadata>>> get() = _driverList + + var previouslySelectedDriver = 0 + var selectedDriver = -1 + + private val _selectedDriverMetadata = + MutableStateFlow( + GpuDriverHelper.customDriverData.name + ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver) + ) + val selectedDriverMetadata: StateFlow<String> get() = _selectedDriverMetadata + + private val _newDriverInstalled = MutableStateFlow(false) + val newDriverInstalled: StateFlow<Boolean> get() = _newDriverInstalled + + val driversToDelete = mutableListOf<String>() + + val isInteractionAllowed + get() = !areDriversLoading.value && isDriverReady.value && !isDeletingDrivers.value + + init { + _areDriversLoading.value = true + viewModelScope.launch { + withContext(Dispatchers.IO) { + val drivers = GpuDriverHelper.getDrivers() + val currentDriverMetadata = GpuDriverHelper.customDriverData + for (i in drivers.indices) { + if (drivers[i].second == currentDriverMetadata) { + setSelectedDriverIndex(i) + break + } + } + + // If a user had installed a driver before the manager was implemented, this zips + // the installed driver to UserData/gpu_drivers/CustomDriver.zip so that it can + // be indexed and exported as expected. + if (selectedDriver == -1) { + val driverToSave = + File(GpuDriverHelper.driverStoragePath, "CustomDriver.zip") + driverToSave.createNewFile() + FileUtil.zipFromInternalStorage( + File(GpuDriverHelper.driverInstallationPath!!), + GpuDriverHelper.driverInstallationPath!!, + BufferedOutputStream(driverToSave.outputStream()) + ) + drivers.add(Pair(driverToSave.path, currentDriverMetadata)) + setSelectedDriverIndex(drivers.size - 1) + } + + _driverList.value = drivers + _areDriversLoading.value = false + } + } + } + + fun setSelectedDriverIndex(value: Int) { + if (selectedDriver != -1) { + previouslySelectedDriver = selectedDriver + } + selectedDriver = value + } + + fun setNewDriverInstalled(value: Boolean) { + _newDriverInstalled.value = value + } + + fun addDriver(driverData: Pair<String, GpuDriverMetadata>) { + val driverIndex = _driverList.value.indexOfFirst { it == driverData } + if (driverIndex == -1) { + setSelectedDriverIndex(_driverList.value.size) + _driverList.value.add(driverData) + _selectedDriverMetadata.value = driverData.second.name + ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver) + } else { + setSelectedDriverIndex(driverIndex) + } + } + + fun removeDriver(driverData: Pair<String, GpuDriverMetadata>) { + _driverList.value.remove(driverData) + } + + fun onCloseDriverManager() { + _isDeletingDrivers.value = true + viewModelScope.launch { + withContext(Dispatchers.IO) { + driversToDelete.forEach { + val driver = File(it) + if (driver.exists()) { + driver.delete() + } + } + driversToDelete.clear() + _isDeletingDrivers.value = false + } + } + + if (GpuDriverHelper.customDriverData == driverList.value[selectedDriver].second) { + return + } + + _isDriverReady.value = false + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (selectedDriver == 0) { + GpuDriverHelper.installDefaultDriver() + setDriverReady() + return@withContext + } + + val driverToInstall = File(driverList.value[selectedDriver].first) + if (driverToInstall.exists()) { + GpuDriverHelper.installCustomDriver(driverToInstall) + } else { + GpuDriverHelper.installDefaultDriver() + } + setDriverReady() + } + } + } + + private fun setDriverReady() { + _isDriverReady.value = true + _selectedDriverMetadata.value = GpuDriverHelper.customDriverData.name + ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt new file mode 100644 index 000000000..36a7c97b8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.StringRes + +data class Installable( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt new file mode 100644 index 000000000..36ffd08d2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel + +class MessageDialogViewModel : ViewModel() { + var dismissAction: () -> Unit = {} + + fun clear() { + dismissAction = {} + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt index 531c2aaf0..16a794dee 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt @@ -20,12 +20,20 @@ class TaskViewModel : ViewModel() { val isRunning: StateFlow<Boolean> get() = _isRunning private val _isRunning = MutableStateFlow(false) + val cancelled: StateFlow<Boolean> get() = _cancelled + private val _cancelled = MutableStateFlow(false) + lateinit var task: () -> Any fun clear() { _result.value = Any() _isComplete.value = false _isRunning.value = false + _cancelled.value = false + } + + fun setCancelled(value: Boolean) { + _cancelled.value = value } fun runTask() { @@ -42,3 +50,9 @@ class TaskViewModel : ViewModel() { } } } + +enum class TaskState { + Completed, + Failed, + Cancelled +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt index c055c2e35..a13faf3c7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -352,7 +352,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : } private fun addOverlayControls(layout: String) { - val windowSize = getSafeScreenSize(context) + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) if (preferences.getBoolean(Settings.PREF_BUTTON_A, true)) { overlayButtons.add( initializeOverlayButton( @@ -593,7 +593,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : } private fun saveControlPosition(prefId: String, x: Int, y: Int, layout: String) { - val windowSize = getSafeScreenSize(context) + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) val min = windowSize.first val max = windowSize.second PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() @@ -968,14 +968,17 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : * @return A pair of points, the first being the top left corner of the safe area, * the second being the bottom right corner of the safe area */ - private fun getSafeScreenSize(context: Context): Pair<Point, Point> { + private fun getSafeScreenSize( + context: Context, + screenSize: Pair<Int, Int> + ): Pair<Point, Point> { // Get screen size val windowMetrics = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(context as Activity) - var maxY = windowMetrics.bounds.height().toFloat() - var maxX = windowMetrics.bounds.width().toFloat() - var minY = 0 + var maxX = screenSize.first.toFloat() + var maxY = screenSize.second.toFloat() var minX = 0 + var minY = 0 // If we have API access, calculate the safe area to draw the overlay var cutoutLeft = 0 diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index b6b6c6c17..233aa4101 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.ui.main import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.DocumentsContract import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager @@ -19,6 +20,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -27,11 +29,10 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import com.google.android.material.color.MaterialColors -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationBarView +import kotlinx.coroutines.CoroutineScope import java.io.File import java.io.FilenameFilter -import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -40,22 +41,40 @@ import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.databinding.ActivityMainBinding -import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.getPublicFilesDir import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.TaskState +import org.yuzu.yuzu_emu.model.TaskViewModel import org.yuzu.yuzu_emu.utils.* +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream class MainActivity : AppCompatActivity(), ThemeProvider { private lateinit var binding: ActivityMainBinding private val homeViewModel: HomeViewModel by viewModels() private val gamesViewModel: GamesViewModel by viewModels() + private val taskViewModel: TaskViewModel by viewModels() override var themeId: Int = 0 + private val savesFolder + get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" + + // Get first subfolder in saves folder (should be the user folder) + val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" + private var lastZipCreated: File? = null + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } @@ -307,6 +326,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { fun processKey(result: Uri): Boolean { if (FileUtil.getExtension(result) != "keys") { MessageDialogFragment.newInstance( + this, titleId = R.string.reading_keys_failure, descriptionId = R.string.install_prod_keys_failure_extension_description ).show(supportFragmentManager, MessageDialogFragment.TAG) @@ -320,11 +340,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val dstPath = DirectoryInitialization.userDirectory + "/keys/" if (FileUtil.copyUriToInternalStorage( - applicationContext, result, dstPath, "prod.keys" - ) + ) != null ) { if (NativeLibrary.reloadKeys()) { Toast.makeText( @@ -336,6 +355,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return true } else { MessageDialogFragment.newInstance( + this, titleId = R.string.invalid_keys_error, descriptionId = R.string.install_keys_failure_description, helpLinkId = R.string.dumping_keys_quickstart_link @@ -371,11 +391,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val task: () -> Any = { var messageToShow: Any try { - FileUtil.unzip(inputZip, cacheFirmwareDir) + FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheFirmwareDir) val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { MessageDialogFragment.newInstance( + this, titleId = R.string.firmware_installed_failure, descriptionId = R.string.firmware_installed_failure_description ) @@ -395,7 +416,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { IndeterminateProgressDialogFragment.newInstance( this, R.string.firmware_installing, - task + task = task ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } @@ -407,6 +428,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (FileUtil.getExtension(result) != "bin") { MessageDialogFragment.newInstance( + this, titleId = R.string.reading_keys_failure, descriptionId = R.string.install_amiibo_keys_failure_extension_description ).show(supportFragmentManager, MessageDialogFragment.TAG) @@ -420,11 +442,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val dstPath = DirectoryInitialization.userDirectory + "/keys/" if (FileUtil.copyUriToInternalStorage( - applicationContext, result, dstPath, "key_retail.bin" - ) + ) != null ) { if (NativeLibrary.reloadKeys()) { Toast.makeText( @@ -434,6 +455,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { ).show() } else { MessageDialogFragment.newInstance( + this, titleId = R.string.invalid_keys_error, descriptionId = R.string.install_keys_failure_description, helpLinkId = R.string.dumping_keys_quickstart_link @@ -442,66 +464,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } - val getDriver = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - val takeFlags = - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - contentResolver.takePersistableUriPermission( - result, - takeFlags - ) - - val progressBinding = DialogProgressBarBinding.inflate(layoutInflater) - progressBinding.progressBar.isIndeterminate = true - val installationDialog = MaterialAlertDialogBuilder(this) - .setTitle(R.string.installing_driver) - .setView(progressBinding.root) - .show() - - lifecycleScope.launch { - withContext(Dispatchers.IO) { - // Ignore file exceptions when a user selects an invalid zip - try { - GpuDriverHelper.installCustomDriver(applicationContext, result) - } catch (_: IOException) { - } - - withContext(Dispatchers.Main) { - installationDialog.dismiss() - - val driverName = GpuDriverHelper.customDriverName - if (driverName != null) { - Toast.makeText( - applicationContext, - getString( - R.string.select_gpu_driver_install_success, - driverName - ), - Toast.LENGTH_SHORT - ).show() - } else { - Toast.makeText( - applicationContext, - R.string.select_gpu_driver_error, - Toast.LENGTH_LONG - ).show() - } - } - } - } - } - val installGameUpdate = registerForActivityResult( ActivityResultContracts.OpenMultipleDocuments() ) { documents: List<Uri> -> if (documents.isNotEmpty()) { IndeterminateProgressDialogFragment.newInstance( this@MainActivity, - R.string.install_game_content + R.string.installing_game_content ) { var installSuccess = 0 var installOverwrite = 0 @@ -509,7 +478,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { var errorExtension = 0 var errorOther = 0 documents.forEach { - when (NativeLibrary.installFileToNand(it.toString())) { + when ( + NativeLibrary.installFileToNand( + it.toString(), + FileUtil.getExtension(it) + ) + ) { NativeLibrary.InstallFileToNandResult.Success -> { installSuccess += 1 } @@ -583,12 +557,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider { installResult.append(separator) } return@newInstance MessageDialogFragment.newInstance( + this, titleId = R.string.install_game_content_failure, descriptionString = installResult.toString().trim(), helpLinkId = R.string.install_game_content_help_link ) } else { return@newInstance MessageDialogFragment.newInstance( + this, titleId = R.string.install_game_content_success, descriptionString = installResult.toString().trim() ) @@ -596,4 +572,228 @@ class MainActivity : AppCompatActivity(), ThemeProvider { }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) } } + + val exportUserData = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + this, + R.string.exporting_user_data, + true + ) { + val zipResult = FileUtil.zipFromInternalStorage( + File(DirectoryInitialization.userDirectory!!), + DirectoryInitialization.userDirectory!!, + BufferedOutputStream(contentResolver.openOutputStream(result)), + taskViewModel.cancelled + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.user_data_export_success) + TaskState.Failed -> R.string.export_failed + TaskState.Cancelled -> R.string.user_data_export_cancelled + } + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + val importUserData = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + this, + R.string.importing_user_data + ) { + val checkStream = + ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) + var isYuzuBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + val itemName = ze!!.name.trim() + if (itemName == "/config/config.ini" || itemName == "config/config.ini") { + isYuzuBackup = true + return@use + } + } + } + if (!isYuzuBackup) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_yuzu_backup, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Clear existing user data + File(DirectoryInitialization.userDirectory!!).deleteRecursively() + + // Copy archive to internal storage + try { + FileUtil.unzipToInternalStorage( + BufferedInputStream(contentResolver.openInputStream(result)), + File(DirectoryInitialization.userDirectory!!) + ) + } catch (e: Exception) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.import_failed, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Reinitialize relevant data + NativeLibrary.initializeEmulation() + gamesViewModel.reloadGames(false) + + return@newInstance getString(R.string.user_data_import_success) + }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + /** + * Zips the save files located in the given folder path and creates a new zip file with the current date and time. + * @return true if the zip file is successfully created, false otherwise. + */ + private fun zipSave(): Boolean { + try { + val tempFolder = File(getPublicFilesDir().canonicalPath, "temp") + tempFolder.mkdirs() + val saveFolder = File(savesFolderRoot) + val outputZipFile = File( + tempFolder, + "yuzu saves - ${ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.zip" + ) + outputZipFile.createNewFile() + val result = FileUtil.zipFromInternalStorage( + saveFolder, + savesFolderRoot, + BufferedOutputStream(FileOutputStream(outputZipFile)) + ) + if (result == TaskState.Failed) { + return false + } + lastZipCreated = outputZipFile + } catch (e: Exception) { + return false + } + return true + } + + /** + * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. + */ + fun exportSave() { + CoroutineScope(Dispatchers.IO).launch { + val wasZipCreated = zipSave() + val lastZipFile = lastZipCreated + if (!wasZipCreated || lastZipFile == null) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@MainActivity, + getString(R.string.export_save_failed), + Toast.LENGTH_LONG + ).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + val file = DocumentFile.fromSingleUri( + this@MainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}" + ) + )!! + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(file.uri, "application/zip") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, file.uri) + startForResultExportSave.launch( + Intent.createChooser( + intent, + getString(R.string.share_save_file) + ) + ) + } + } + } + + private val startForResultExportSave = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> + File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively() + } + + val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + NativeLibrary.initializeEmptyUserDirectory() + + val inputZip = contentResolver.openInputStream(result) + // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid. + var validZip = false + val savesFolder = File(savesFolderRoot) + val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + if (inputZip == null) { + Toast.makeText( + applicationContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + return@registerForActivityResult + } + + val filterTitleId = + FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } + + try { + CoroutineScope(Dispatchers.IO).launch { + FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir) + cacheSaveDir.list(filterTitleId)?.forEach { savePath -> + File(savesFolder, savePath).deleteRecursively() + File(cacheSaveDir, savePath).copyRecursively( + File(savesFolder, savePath), + true + ) + validZip = true + } + + withContext(Dispatchers.Main) { + if (!validZip) { + MessageDialogFragment.newInstance( + this@MainActivity, + titleId = R.string.save_file_invalid_zip_structure, + descriptionId = R.string.save_file_invalid_zip_structure_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + Toast.makeText( + applicationContext, + getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + } + + cacheSaveDir.deleteRecursively() + } + } catch (e: Exception) { + Toast.makeText( + applicationContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt index cf226ad94..eafcf9e42 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt @@ -7,7 +7,6 @@ import android.net.Uri import androidx.documentfile.provider.DocumentFile import java.io.File import java.util.* -import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.MinimalDocumentFile class DocumentsTree { @@ -22,7 +21,7 @@ class DocumentsTree { fun openContentUri(filepath: String, openMode: String?): Int { val node = resolvePath(filepath) ?: return -1 - return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode) + return FileUtil.openContentUri(node.uri.toString(), openMode) } fun getFileSize(filepath: String): Long { @@ -30,7 +29,7 @@ class DocumentsTree { return if (node == null || node.isDirectory) { 0 } else { - FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString()) + FileUtil.getFileSize(node.uri.toString()) } } @@ -67,7 +66,7 @@ class DocumentsTree { * @param parent parent node of this level */ private fun structTree(parent: DocumentsNode) { - val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!) + val documents = FileUtil.listFiles(parent.uri!!) for (document in documents) { val node = DocumentsNode(document) node.parent = parent diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index 142af5f26..5ee74a52c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -3,14 +3,13 @@ package org.yuzu.yuzu_emu.utils -import android.content.Context import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.flow.StateFlow import java.io.BufferedInputStream import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.net.URLDecoder @@ -18,6 +17,11 @@ import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import org.yuzu.yuzu_emu.model.TaskState +import java.io.BufferedOutputStream +import java.lang.NullPointerException +import java.nio.charset.StandardCharsets +import java.util.zip.ZipOutputStream object FileUtil { const val PATH_TREE = "tree" @@ -25,6 +29,8 @@ object FileUtil { const val APPLICATION_OCTET_STREAM = "application/octet-stream" const val TEXT_PLAIN = "text/plain" + private val context get() = YuzuApplication.appContext + /** * Create a file from directory with filename. * @param context Application context @@ -32,11 +38,11 @@ object FileUtil { * @param filename file display name. * @return boolean */ - fun createFile(context: Context?, directory: String?, filename: String): DocumentFile? { + fun createFile(directory: String?, filename: String): DocumentFile? { var decodedFilename = filename try { val directoryUri = Uri.parse(directory) - val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD) var mimeType = APPLICATION_OCTET_STREAM if (decodedFilename.endsWith(".txt")) { @@ -52,16 +58,15 @@ object FileUtil { /** * Create a directory from directory with filename. - * @param context Application context * @param directory parent path for directory. * @param directoryName directory display name. * @return boolean */ - fun createDir(context: Context?, directory: String?, directoryName: String?): DocumentFile? { + fun createDir(directory: String?, directoryName: String?): DocumentFile? { var decodedDirectoryName = directoryName try { val directoryUri = Uri.parse(directory) - val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD) val isExist = parent.findFile(decodedDirectoryName) return isExist ?: parent.createDirectory(decodedDirectoryName) @@ -73,13 +78,12 @@ object FileUtil { /** * Open content uri and return file descriptor to JNI. - * @param context Application context * @param path Native content uri path * @param openMode will be one of "r", "r", "rw", "wa", "rwa" * @return file descriptor */ @JvmStatic - fun openContentUri(context: Context, path: String, openMode: String?): Int { + fun openContentUri(path: String, openMode: String?): Int { try { val uri = Uri.parse(path) val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!) @@ -99,11 +103,10 @@ object FileUtil { /** * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow * This function will be faster than DoucmentFile.listFiles - * @param context Application context * @param uri Directory uri. * @return CheapDocument lists. */ - fun listFiles(context: Context, uri: Uri): Array<MinimalDocumentFile> { + fun listFiles(uri: Uri): Array<MinimalDocumentFile> { val resolver = context.contentResolver val columns = arrayOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, @@ -141,7 +144,7 @@ object FileUtil { * @param path Native content uri path * @return bool */ - fun exists(context: Context, path: String?): Boolean { + fun exists(path: String?): Boolean { var c: Cursor? = null try { val mUri = Uri.parse(path) @@ -161,7 +164,7 @@ object FileUtil { * @param path content uri path * @return bool */ - fun isDirectory(context: Context, path: String): Boolean { + fun isDirectory(path: String): Boolean { val resolver = context.contentResolver val columns = arrayOf( DocumentsContract.Document.COLUMN_MIME_TYPE @@ -206,10 +209,10 @@ object FileUtil { return filename } - fun getFilesName(context: Context, path: String): Array<String> { + fun getFilesName(path: String): Array<String> { val uri = Uri.parse(path) val files: MutableList<String> = ArrayList() - for (file in listFiles(context, uri)) { + for (file in listFiles(uri)) { files.add(file.filename) } return files.toTypedArray() @@ -221,7 +224,7 @@ object FileUtil { * @return long file size */ @JvmStatic - fun getFileSize(context: Context, path: String): Long { + fun getFileSize(path: String): Long { val resolver = context.contentResolver val columns = arrayOf( DocumentsContract.Document.COLUMN_SIZE @@ -241,71 +244,100 @@ object FileUtil { return size } + /** + * Creates an input stream with a given [Uri] and copies its data to the given path. This will + * overwrite any pre-existing files. + * + * @param sourceUri The [Uri] to copy data from + * @param destinationParentPath Destination directory + * @param destinationFilename Optionally renames the file once copied + */ fun copyUriToInternalStorage( - context: Context, - sourceUri: Uri?, + sourceUri: Uri, destinationParentPath: String, - destinationFilename: String - ): Boolean { - var input: InputStream? = null - var output: FileOutputStream? = null + destinationFilename: String = "" + ): File? = try { - input = context.contentResolver.openInputStream(sourceUri!!) - output = FileOutputStream("$destinationParentPath/$destinationFilename") - val buffer = ByteArray(1024) - var len: Int - while (input!!.read(buffer).also { len = it } != -1) { - output.write(buffer, 0, len) - } - output.flush() - return true - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot copy file, error: " + e.message) - } finally { - if (input != null) { - try { - input.close() - } catch (e: IOException) { - Log.error("[FileUtil]: Cannot close input file, error: " + e.message) - } + val fileName = + if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename" + val inputStream = context.contentResolver.openInputStream(sourceUri)!! + + val destinationFile = File("$destinationParentPath$fileName") + if (destinationFile.exists()) { + destinationFile.delete() } - if (output != null) { - try { - output.close() - } catch (e: IOException) { - Log.error("[FileUtil]: Cannot close output file, error: " + e.message) - } + + destinationFile.outputStream().use { fos -> + inputStream.use { it.copyTo(fos) } } + destinationFile + } catch (e: IOException) { + null + } catch (e: NullPointerException) { + null } - return false - } /** * Extracts the given zip file into the given directory. - * @exception IOException if the file was being created outside of the target directory */ @Throws(SecurityException::class) - fun unzip(zipStream: InputStream, destDir: File): Boolean { - ZipInputStream(BufferedInputStream(zipStream)).use { zis -> + fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) { + ZipInputStream(zipStream).use { zis -> var entry: ZipEntry? = zis.nextEntry while (entry != null) { - val entryName = entry.name - val entryFile = File(destDir, entryName) - if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { - throw SecurityException("Entry is outside of the target dir: " + entryFile.name) + val newFile = File(destDir, entry.name) + val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile + + if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Zip file attempted path traversal! ${entry.name}") + } + + if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { + throw IOException("Failed to create directory $destinationDirectory") } - if (entry.isDirectory) { - entryFile.mkdirs() - } else { - entryFile.parentFile?.mkdirs() - entryFile.createNewFile() - entryFile.outputStream().use { fos -> zis.copyTo(fos) } + + if (!entry.isDirectory) { + newFile.outputStream().use { fos -> zis.copyTo(fos) } } entry = zis.nextEntry } } + } - return true + /** + * Creates a zip file from a directory within internal storage + * @param inputFile File representation of the item that will be zipped + * @param rootDir Directory containing the inputFile + * @param outputStream Stream where the zip file will be output + */ + fun zipFromInternalStorage( + inputFile: File, + rootDir: String, + outputStream: BufferedOutputStream, + cancelled: StateFlow<Boolean>? = null + ): TaskState { + try { + ZipOutputStream(outputStream).use { zos -> + inputFile.walkTopDown().forEach { file -> + if (cancelled?.value == true) { + return TaskState.Cancelled + } + + if (!file.isDirectory) { + val entryName = + file.absolutePath.removePrefix(rootDir).removePrefix("/") + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + if (file.isFile) { + file.inputStream().use { fis -> fis.copyTo(zos) } + } + } + } + } + } catch (e: Exception) { + return TaskState.Failed + } + return TaskState.Completed } fun isRootTreeUri(uri: Uri): Boolean { @@ -329,4 +361,12 @@ object FileUtil { return fileName.substring(fileName.lastIndexOf(".") + 1) .lowercase() } + + @Throws(IOException::class) + fun getStringFromFile(file: File): String = + String(file.readBytes(), StandardCharsets.UTF_8) + + @Throws(IOException::class) + fun getStringFromInputStream(stream: InputStream): String = + String(stream.readBytes(), StandardCharsets.UTF_8) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index e0ee29c9b..9001ca9ab 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -30,7 +30,7 @@ object GameHelper { // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() - addGamesRecursive(games, FileUtil.listFiles(context, gamesUri), 3) + addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3) // Cache list of games found on disk val serializedGames = mutableSetOf<String>() @@ -58,7 +58,7 @@ object GameHelper { if (it.isDirectory) { addGamesRecursive( games, - FileUtil.listFiles(YuzuApplication.appContext, it.uri), + FileUtil.listFiles(it.uri), depth - 1 ) } else { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt index 1d4695a2a..f6882ce6c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt @@ -3,64 +3,33 @@ package org.yuzu.yuzu_emu.utils -import android.content.Context import android.net.Uri +import android.os.Build import java.io.BufferedInputStream import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.io.IOException -import java.util.zip.ZipInputStream import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage +import org.yuzu.yuzu_emu.YuzuApplication +import java.util.zip.ZipException +import java.util.zip.ZipFile object GpuDriverHelper { private const val META_JSON_FILENAME = "meta.json" - private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip" private var fileRedirectionPath: String? = null - private var driverInstallationPath: String? = null + var driverInstallationPath: String? = null private var hookLibPath: String? = null - @Throws(IOException::class) - private fun unzip(zipFilePath: String, destDir: String) { - val dir = File(destDir) - - // Create output directory if it doesn't exist - if (!dir.exists()) dir.mkdirs() - - // Unpack the files. - val inputStream = FileInputStream(zipFilePath) - val zis = ZipInputStream(BufferedInputStream(inputStream)) - val buffer = ByteArray(1024) - var ze = zis.nextEntry - while (ze != null) { - val newFile = File(destDir, ze.name) - val canonicalPath = newFile.canonicalPath - if (!canonicalPath.startsWith(destDir + ze.name)) { - throw SecurityException("Zip file attempted path traversal! " + ze.name) - } - - newFile.parentFile!!.mkdirs() - val fos = FileOutputStream(newFile) - var len: Int - while (zis.read(buffer).also { len = it } > 0) { - fos.write(buffer, 0, len) - } - fos.close() - zis.closeEntry() - ze = zis.nextEntry - } - zis.closeEntry() - } + val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/" - fun initializeDriverParameters(context: Context) { + fun initializeDriverParameters() { try { // Initialize the file redirection directory. - fileRedirectionPath = - context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + fileRedirectionPath = YuzuApplication.appContext + .getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" // Initialize the driver installation directory. - driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/" + driverInstallationPath = YuzuApplication.appContext + .filesDir.canonicalPath + "/gpu_driver/" } catch (e: IOException) { throw RuntimeException(e) } @@ -69,68 +38,169 @@ object GpuDriverHelper { initializeDirectories() // Initialize hook libraries directory. - hookLibPath = context.applicationInfo.nativeLibraryDir + "/" + hookLibPath = YuzuApplication.appContext.applicationInfo.nativeLibraryDir + "/" // Initialize GPU driver. NativeLibrary.initializeGpuDriver( hookLibPath, driverInstallationPath, - customDriverLibraryName, + customDriverData.libraryName, fileRedirectionPath ) } - fun installDefaultDriver(context: Context) { + fun getDrivers(): MutableList<Pair<String, GpuDriverMetadata>> { + val driverZips = File(driverStoragePath).listFiles() + val drivers: MutableList<Pair<String, GpuDriverMetadata>> = + driverZips + ?.mapNotNull { + val metadata = getMetadataFromZip(it) + metadata.name?.let { _ -> Pair(it.path, metadata) } + } + ?.sortedByDescending { it: Pair<String, GpuDriverMetadata> -> it.second.name } + ?.distinct() + ?.toMutableList() ?: mutableListOf() + + // TODO: Get system driver information + drivers.add(0, Pair("", GpuDriverMetadata())) + return drivers + } + + fun installDefaultDriver() { // Removing the installed driver will result in the backend using the default system driver. - val driverInstallationDir = File(driverInstallationPath!!) - deleteRecursive(driverInstallationDir) - initializeDriverParameters(context) + File(driverInstallationPath!!).deleteRecursively() + initializeDriverParameters() + } + + fun copyDriverToInternalStorage(driverUri: Uri): Boolean { + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + return true } - fun installCustomDriver(context: Context, driverPathUri: Uri?) { + /** + * Copies driver zip into user data directory so that it can be exported along with + * other user data and also unzipped into the installation directory + */ + fun installCustomDriver(driverUri: Uri): Boolean { // Revert to system default in the event the specified driver is bad. - installDefaultDriver(context) + installDefaultDriver() // Ensure we have directories. initializeDirectories() - // Copy the zip file URI into our private storage. - copyUriToInternalStorage( - context, - driverPathUri, - driverInstallationPath!!, - DRIVER_INTERNAL_FILENAME - ) + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } // Unzip the driver. try { - unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!) + FileUtil.unzipToInternalStorage( + BufferedInputStream(copiedFile.inputStream()), + File(driverInstallationPath!!) + ) } catch (e: SecurityException) { - return + return false } // Initialize the driver parameters. - initializeDriverParameters(context) + initializeDriverParameters() + + return true } - external fun supportsCustomDriverLoading(): Boolean + /** + * Unzips driver into installation directory + */ + fun installCustomDriver(driver: File): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() - // Parse the custom driver metadata to retrieve the name. - val customDriverName: String? - get() { - val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) - return metadata.name + // Ensure we have directories. + initializeDirectories() + + // Validate driver + val metadata = getMetadataFromZip(driver) + if (metadata.name == null) { + driver.delete() + return false } - // Parse the custom driver metadata to retrieve the library name. - private val customDriverLibraryName: String? - get() { - // Parse the custom driver metadata to retrieve the library name. - val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) - return metadata.libraryName + // Unzip the driver to the private installation directory + try { + FileUtil.unzipToInternalStorage( + BufferedInputStream(driver.inputStream()), + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false } - private fun initializeDirectories() { + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Takes in a zip file and reads the meta.json file for presentation to the UI + * + * @param driver Zip containing driver and meta.json file + * @return A non-null [GpuDriverMetadata] instance that may have null members + */ + fun getMetadataFromZip(driver: File): GpuDriverMetadata { + try { + ZipFile(driver).use { zf -> + val entries = zf.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { + zf.getInputStream(entry).use { + return GpuDriverMetadata(it, entry.size) + } + } + } + } + } catch (_: ZipException) { + } + return GpuDriverMetadata() + } + + external fun supportsCustomDriverLoading(): Boolean + + // Parse the custom driver metadata to retrieve the name. + val customDriverData: GpuDriverMetadata + get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) + + fun initializeDirectories() { // Ensure the file redirection directory exists. val fileRedirectionDir = File(fileRedirectionPath!!) if (!fileRedirectionDir.exists()) { @@ -141,14 +211,10 @@ object GpuDriverHelper { if (!driverInstallationDir.exists()) { driverInstallationDir.mkdirs() } - } - - private fun deleteRecursive(fileOrDirectory: File) { - if (fileOrDirectory.isDirectory) { - for (child in fileOrDirectory.listFiles()!!) { - deleteRecursive(child) - } + // Ensure the driver storage directory exists + val driverStorageDirectory = File(driverStoragePath) + if (!driverStorageDirectory.exists()) { + driverStorageDirectory.mkdirs() } - fileOrDirectory.delete() } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt index a4e64070a..511a4171a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt @@ -4,29 +4,29 @@ package org.yuzu.yuzu_emu.utils import java.io.IOException -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Paths import org.json.JSONException import org.json.JSONObject +import java.io.File +import java.io.InputStream -class GpuDriverMetadata(metadataFilePath: String) { - var name: String? = null - var description: String? = null - var author: String? = null - var vendor: String? = null - var driverVersion: String? = null - var minApi = 0 - var libraryName: String? = null +class GpuDriverMetadata { + /** + * Tries to get driver metadata information from a meta.json [File] + * + * @param metadataFile meta.json file provided with a GPU driver + */ + constructor(metadataFile: File) { + if (metadataFile.length() > MAX_META_SIZE_BYTES) { + return + } - init { try { - val json = JSONObject(getStringFromFile(metadataFilePath)) + val json = JSONObject(FileUtil.getStringFromFile(metadataFile)) name = json.getString("name") description = json.getString("description") author = json.getString("author") vendor = json.getString("vendor") - driverVersion = json.getString("driverVersion") + version = json.getString("driverVersion") minApi = json.getInt("minApi") libraryName = json.getString("libraryName") } catch (e: JSONException) { @@ -36,12 +36,84 @@ class GpuDriverMetadata(metadataFilePath: String) { } } - companion object { - @Throws(IOException::class) - private fun getStringFromFile(filePath: String): String { - val path = Paths.get(filePath) - val bytes = Files.readAllBytes(path) - return String(bytes, StandardCharsets.UTF_8) + /** + * Tries to get driver metadata information from an input stream that's intended to be + * from a zip file + * + * @param metadataStream ZipEntry input stream + * @param size Size of the file in bytes + */ + constructor(metadataStream: InputStream, size: Long) { + if (size > MAX_META_SIZE_BYTES) { + return } + + try { + val json = JSONObject(FileUtil.getStringFromInputStream(metadataStream)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + version = json.getString("driverVersion") + minApi = json.getInt("minApi") + libraryName = json.getString("libraryName") + } catch (e: JSONException) { + // JSON is malformed, ignore and treat as unsupported metadata. + } catch (e: IOException) { + // File is inaccessible, ignore and treat as unsupported metadata. + } + } + + /** + * Creates an empty metadata instance + */ + constructor() + + override fun equals(other: Any?): Boolean { + if (other !is GpuDriverMetadata) { + return false + } + + return other.name == name && + other.description == description && + other.author == author && + other.vendor == vendor && + other.version == version && + other.minApi == minApi && + other.libraryName == libraryName + } + + override fun hashCode(): Int { + var result = name?.hashCode() ?: 0 + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + (author?.hashCode() ?: 0) + result = 31 * result + (vendor?.hashCode() ?: 0) + result = 31 * result + (version?.hashCode() ?: 0) + result = 31 * result + minApi + result = 31 * result + (libraryName?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + """ + Name - $name + Description - $description + Author - $author + Vendor - $vendor + Version - $version + Min API - $minApi + Library Name - $libraryName + """.trimMargin().trimIndent() + + var name: String? = null + var description: String? = null + var author: String? = null + var vendor: String? = null + var version: String? = null + var minApi = 0 + var libraryName: String? = null + + companion object { + private const val MAX_META_SIZE_BYTES = 500000 } } diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp index a890c6604..a7e414b81 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window.cpp +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -11,6 +11,12 @@ #include "jni/emu_window/emu_window.h" void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + m_window_width = ANativeWindow_getWidth(surface); + m_window_height = ANativeWindow_getHeight(surface); + + // Ensures that we emulate with the correct aspect ratio. + UpdateCurrentFramebufferLayout(m_window_width, m_window_height); + window_info.render_surface = reinterpret_cast<void*>(surface); } @@ -62,14 +68,8 @@ EmuWindow_Android::EmuWindow_Android(InputCommon::InputSubsystem* input_subsyste return; } - m_window_width = ANativeWindow_getWidth(surface); - m_window_height = ANativeWindow_getHeight(surface); - - // Ensures that we emulate with the correct aspect ratio. - UpdateCurrentFramebufferLayout(m_window_width, m_window_height); - + OnSurfaceChanged(surface); window_info.type = Core::Frontend::WindowSystemType::Android; - window_info.render_surface = reinterpret_cast<void*>(surface); m_input_subsystem->Initialize(); } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index f31fe054b..598f4e8bf 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -13,6 +13,8 @@ #include <android/api-level.h> #include <android/native_window_jni.h> +#include <common/fs/fs.h> +#include <core/file_sys/savedata_factory.h> #include <core/loader/nro.h> #include <jni.h> @@ -102,7 +104,7 @@ public: m_native_window = native_window; } - int InstallFileToNand(std::string filename) { + int InstallFileToNand(std::string filename, std::string file_extension) { jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest, std::size_t block_size) { if (src == nullptr || dest == nullptr) { @@ -134,15 +136,11 @@ public: m_system.GetFileSystemController().CreateFactories(*m_vfs); [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp; - if (filename.ends_with("nsp")) { + if (file_extension == "nsp") { nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); if (nsp->IsExtractedType()) { return InstallError; } - } else if (filename.ends_with("xci")) { - jconst xci = - std::make_shared<FileSys::XCI>(m_vfs->OpenFile(filename, FileSys::Mode::Read)); - nsp = xci->GetSecurePartitionNSP(); } else { return ErrorFilenameExtension; } @@ -220,7 +218,6 @@ public: return; } m_window->OnSurfaceChanged(m_native_window); - m_system.Renderer().NotifySurfaceChanged(); } void ConfigureFilesystemProvider(const std::string& filepath) { @@ -607,8 +604,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject } int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance, - [[maybe_unused]] jstring j_file) { - return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file)); + jstring j_file, + jstring j_file_extension) { + return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file), + GetJString(env, j_file_extension)); } void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz, @@ -879,4 +878,24 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code); } +void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv* env, + jobject instance) { + const auto nand_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir); + auto vfs_nand_dir = EmulationSession::GetInstance().System().GetFilesystem()->OpenDirectory( + Common::FS::PathToUTF8String(nand_dir), FileSys::Mode::Read); + + Service::Account::ProfileManager manager; + const auto user_id = manager.GetUser(static_cast<std::size_t>(0)); + ASSERT(user_id); + + const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( + EmulationSession::GetInstance().System(), vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser, + FileSys::SaveDataType::SaveData, 1, user_id->AsU128(), 0); + + const auto full_path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path); + if (!Common::FS::CreateParentDirs(full_path)) { + LOG_WARNING(Frontend, "Failed to create full path of the default user's save directory"); + } +} + } // extern "C" diff --git a/src/android/app/src/main/res/drawable/ic_build.xml b/src/android/app/src/main/res/drawable/ic_build.xml new file mode 100644 index 000000000..91d52f1b8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_build.xml @@ -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="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_delete.xml b/src/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..d26a79711 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_delete.xml @@ -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="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_export.xml b/src/android/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 000000000..463d2f41c --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_export.xml @@ -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="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_import.xml b/src/android/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 000000000..3a99dd5e6 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_import.xml @@ -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="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" /> +</vector> diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml index 8a026a30a..a187665f2 100644 --- a/src/android/app/src/main/res/layout/activity_settings.xml +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -22,7 +22,7 @@ <View android:id="@+id/navigation_bar_shade" - android:layout_width="match_parent" + android:layout_width="0dp" android:layout_height="1px" android:background="@android:color/transparent" android:clickable="false" diff --git a/src/android/app/src/main/res/layout/card_driver_option.xml b/src/android/app/src/main/res/layout/card_driver_option.xml new file mode 100644 index 000000000..1dd9a6d7d --- /dev/null +++ b/src/android/app/src/main/res/layout/card_driver_option.xml @@ -0,0 +1,89 @@ +<?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" + style="?attr/materialCardViewOutlinedStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + android:layout_marginVertical="12dp" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:focusable="true"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_gravity="center" + android:padding="16dp"> + + <RadioButton + android:id="@+id/radio_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:clickable="false" + android:checked="false" /> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:layout_gravity="center_vertical"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/title" + style="@style/TextAppearance.Material3.TitleMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:ellipsize="none" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + tools:text="@string/select_gpu_driver_default" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/version" + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:ellipsize="none" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + tools:text="@string/install_gpu_driver_description" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/description" + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:ellipsize="none" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + tools:text="@string/install_gpu_driver_description" /> + + </LinearLayout> + + <Button + android:id="@+id/button_delete" + style="@style/Widget.Material3.Button.IconButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:contentDescription="@string/delete" + android:tooltipText="@string/delete" + app:icon="@drawable/ic_delete" + app:iconTint="?attr/colorControlNormal" /> + + </LinearLayout> + +</com.google.android.material.card.MaterialCardView> diff --git a/src/android/app/src/main/res/layout/card_installable.xml b/src/android/app/src/main/res/layout/card_installable.xml new file mode 100644 index 000000000..f5b0e3741 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_installable.xml @@ -0,0 +1,71 @@ +<?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" + style="?attr/materialCardViewOutlinedStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + android:layout_marginVertical="12dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:orientation="horizontal" + android:layout_gravity="center"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="16dp" + android:layout_weight="1" + android:orientation="vertical"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/title" + style="@style/TextAppearance.Material3.TitleMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/user_data" + android:textAlignment="viewStart" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/description" + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:text="@string/user_data_description" + android:textAlignment="viewStart" /> + + </LinearLayout> + + <Button + android:id="@+id/button_export" + style="@style/Widget.Material3.Button.IconButton.Filled.Tonal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:contentDescription="@string/export" + android:tooltipText="@string/export" + android:visibility="gone" + app:icon="@drawable/ic_export" + tools:visibility="visible" /> + + <Button + android:id="@+id/button_install" + style="@style/Widget.Material3.Button.IconButton.Filled.Tonal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:layout_marginStart="12dp" + android:contentDescription="@string/string_import" + android:tooltipText="@string/string_import" + android:visibility="gone" + app:icon="@drawable/ic_import" + tools:visibility="visible" /> + + </LinearLayout> + +</com.google.android.material.card.MaterialCardView> diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml index d17711a65..0209ea082 100644 --- a/src/android/app/src/main/res/layout/dialog_progress_bar.xml +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -1,24 +1,8 @@ <?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="match_parent" +<com.google.android.material.progressindicator.LinearProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:orientation="vertical"> - - <com.google.android.material.progressindicator.LinearProgressIndicator - android:id="@+id/progress_bar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="24dp" - app:trackCornerRadius="4dp" /> - - <TextView - android:id="@+id/progress_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="24dp" - android:layout_marginRight="24dp" - android:layout_marginBottom="24dp" - android:gravity="end" /> - -</LinearLayout> + android:id="@+id/progress_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="24dp" + app:trackCornerRadius="4dp" /> diff --git a/src/android/app/src/main/res/layout/fragment_driver_manager.xml b/src/android/app/src/main/res/layout/fragment_driver_manager.xml new file mode 100644 index 000000000..6cea2d164 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_driver_manager.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/coordinator_licenses" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/colorSurface"> + + <androidx.coordinatorlayout.widget.CoordinatorLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/appbar_drivers" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fitsSystemWindows="true" + app:liftOnScrollTargetViewId="@id/list_drivers"> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/toolbar_drivers" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:title="@string/gpu_driver_manager" /> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/list_drivers" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + + </androidx.coordinatorlayout.widget.CoordinatorLayout> + + <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton + android:id="@+id/button_install" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|end" + android:text="@string/install" + app:icon="@drawable/ic_add" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index da97d85c1..750ce094a 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -32,7 +32,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:focusable="false"> + android:focusable="false" + android:clickable="false"> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/loading_layout" @@ -155,7 +156,7 @@ android:id="@+id/in_game_menu" android:layout_width="wrap_content" android:layout_height="match_parent" - android:layout_gravity="start|bottom" + android:layout_gravity="start" app:headerLayout="@layout/header_in_game" app:menu="@menu/menu_in_game" tools:visibility="gone" /> diff --git a/src/android/app/src/main/res/layout/fragment_installables.xml b/src/android/app/src/main/res/layout/fragment_installables.xml new file mode 100644 index 000000000..3a4df81a6 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_installables.xml @@ -0,0 +1,31 @@ +<?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" + android:id="@+id/coordinator_licenses" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/colorSurface"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/appbar_installables" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fitsSystemWindows="true"> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/toolbar_installables" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + app:title="@string/manage_yuzu_data" + app:navigationIcon="@drawable/ic_back" /> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/list_installables" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + +</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml index 2e0ce7a3d..82749359d 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -19,6 +19,12 @@ <action android:id="@+id/action_homeSettingsFragment_to_earlyAccessFragment" app:destination="@id/earlyAccessFragment" /> + <action + android:id="@+id/action_homeSettingsFragment_to_installableFragment" + app:destination="@id/installableFragment" /> + <action + android:id="@+id/action_homeSettingsFragment_to_driverManagerFragment" + app:destination="@id/driverManagerFragment" /> </fragment> <fragment @@ -88,5 +94,13 @@ <action android:id="@+id/action_global_settingsActivity" app:destination="@id/settingsActivity" /> + <fragment + android:id="@+id/installableFragment" + android:name="org.yuzu.yuzu_emu.fragments.InstallableFragment" + android:label="InstallableFragment" /> + <fragment + android:id="@+id/driverManagerFragment" + android:name="org.yuzu.yuzu_emu.fragments.DriverManagerFragment" + android:label="DriverManagerFragment" /> </navigation> diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml index daaa7ffde..72a47fbdb 100644 --- a/src/android/app/src/main/res/values-de/strings.xml +++ b/src/android/app/src/main/res/values-de/strings.xml @@ -79,7 +79,6 @@ <string name="manage_save_data">Speicherdaten verwalten</string> <string name="manage_save_data_description">Speicherdaten gefunden. Bitte wähle unten eine Option aus.</string> <string name="import_export_saves_description">Speicherdaten importieren oder exportieren</string> - <string name="import_export_saves_no_profile">Keine Speicherdaten gefunden. Bitte starte ein Spiel und versuche es erneut.</string> <string name="save_file_imported_success">Erfolgreich importiert</string> <string name="save_file_invalid_zip_structure">Ungültige Speicherverzeichnisstruktur</string> <string name="save_file_invalid_zip_structure_description">Der erste Unterordnername muss die Titel-ID des Spiels sein.</string> @@ -169,9 +168,7 @@ <string name="select_gpu_driver_title">Möchtest du deinen aktuellen GPU-Treiber ersetzen?</string> <string name="select_gpu_driver_install">Installieren</string> <string name="select_gpu_driver_default">Standard</string> - <string name="select_gpu_driver_install_success">%s wurde installiert</string> <string name="select_gpu_driver_use_default">Standard GPU-Treiber wird verwendet</string> - <string name="select_gpu_driver_error">Ungültiger Treiber ausgewählt, Standard-Treiber wird verwendet!</string> <string name="system_gpu_driver">System GPU-Treiber</string> <string name="installing_driver">Treiber wird installiert...</string> diff --git a/src/android/app/src/main/res/values-es/strings.xml b/src/android/app/src/main/res/values-es/strings.xml index e9129cb00..e5bdd5889 100644 --- a/src/android/app/src/main/res/values-es/strings.xml +++ b/src/android/app/src/main/res/values-es/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Administrar datos de guardado</string> <string name="manage_save_data_description">Guardar los datos encontrados. Por favor, seleccione una opción de abajo.</string> <string name="import_export_saves_description">Importar o exportar archivos de guardado</string> - <string name="import_export_saves_no_profile">No se han encontrado datos de guardado. Por favor, ejecute un juego y vuelva a intentarlo.</string> <string name="save_file_imported_success">Importado correctamente</string> <string name="save_file_invalid_zip_structure">Estructura del directorio de guardado no válido</string> <string name="save_file_invalid_zip_structure_description">El nombre de la primera subcarpeta debe ser el Title ID del juego.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">¿Quiere reemplazar el driver de GPU actual?</string> <string name="select_gpu_driver_install">Instalar</string> <string name="select_gpu_driver_default">Predeterminado</string> - <string name="select_gpu_driver_install_success">Instalado %s</string> <string name="select_gpu_driver_use_default">Usando el driver de GPU por defecto </string> - <string name="select_gpu_driver_error">¡Driver no válido, utilizando el predeterminado del sistema!</string> <string name="system_gpu_driver">Driver GPU del sistema</string> <string name="installing_driver">Instalando driver...</string> diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml index 2d99d618e..1e02828aa 100644 --- a/src/android/app/src/main/res/values-fr/strings.xml +++ b/src/android/app/src/main/res/values-fr/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Gérer les données de sauvegarde</string> <string name="manage_save_data_description">Données de sauvegarde trouvées. Veuillez sélectionner une option ci-dessous.</string> <string name="import_export_saves_description">Importer ou exporter des fichiers de sauvegarde</string> - <string name="import_export_saves_no_profile">Aucune données de sauvegarde trouvées. Veuillez lancer un jeu et réessayer.</string> <string name="save_file_imported_success">Importé avec succès</string> <string name="save_file_invalid_zip_structure">Structure de répertoire de sauvegarde non valide</string> <string name="save_file_invalid_zip_structure_description">Le nom du premier sous-dossier doit être l\'identifiant du titre du jeu.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Souhaitez vous remplacer votre pilote actuel ?</string> <string name="select_gpu_driver_install">Installer</string> <string name="select_gpu_driver_default">Défaut</string> - <string name="select_gpu_driver_install_success">%s Installé</string> <string name="select_gpu_driver_use_default">Utilisation du pilote de GPU par défaut</string> - <string name="select_gpu_driver_error">Pilote non valide sélectionné, utilisation du paramètre par défaut du système !</string> <string name="system_gpu_driver">Pilote du GPU du système</string> <string name="installing_driver">Installation du pilote...</string> diff --git a/src/android/app/src/main/res/values-it/strings.xml b/src/android/app/src/main/res/values-it/strings.xml index d9c3de385..09c9345b0 100644 --- a/src/android/app/src/main/res/values-it/strings.xml +++ b/src/android/app/src/main/res/values-it/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Gestisci i salvataggi</string> <string name="manage_save_data_description">Salvataggio non trovato. Seleziona un\'opzione di seguito.</string> <string name="import_export_saves_description">Importa o esporta i salvataggi</string> - <string name="import_export_saves_no_profile">Nessun salvataggio trovato. Avvia un gioco e riprova.</string> <string name="save_file_imported_success">Importato con successo</string> <string name="save_file_invalid_zip_structure">La struttura della cartella dei salvataggi è invalida</string> <string name="save_file_invalid_zip_structure_description">La prima sotto cartella <b>deve</b> chiamarsi come l\'ID del titolo del gioco.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Vuoi sostituire il driver della tua GPU attuale?</string> <string name="select_gpu_driver_install">Installa</string> <string name="select_gpu_driver_default">Predefinito</string> - <string name="select_gpu_driver_install_success">Installato%s</string> <string name="select_gpu_driver_use_default">Utilizza il driver predefinito della GPU.</string> - <string name="select_gpu_driver_error">Il driver selezionato è invalido, è in utilizzo quello predefinito di sistema!</string> <string name="system_gpu_driver">Driver GPU del sistema</string> <string name="installing_driver">Installando i driver...</string> diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml index 7a226cd5c..a0ea78bef 100644 --- a/src/android/app/src/main/res/values-ja/strings.xml +++ b/src/android/app/src/main/res/values-ja/strings.xml @@ -80,7 +80,6 @@ <string name="manage_save_data">セーブデータを管理</string> <string name="manage_save_data_description">セーブデータが見つかりました。以下のオプションから選択してください。</string> <string name="import_export_saves_description">セーブファイルをインポート/エクスポート</string> - <string name="import_export_saves_no_profile">セーブデータがありません。ゲームを起動してから再度お試しください。</string> <string name="save_file_imported_success">インポートが完了しました</string> <string name="save_file_invalid_zip_structure">セーブデータのディレクトリ構造が無効です</string> <string name="save_file_invalid_zip_structure_description">最初のサブフォルダ名は、ゲームのタイトルIDである必要があります。</string> @@ -171,9 +170,7 @@ <string name="select_gpu_driver_title">現在のGPUドライバーを置き換えますか?</string> <string name="select_gpu_driver_install">インストール</string> <string name="select_gpu_driver_default">デフォルト</string> - <string name="select_gpu_driver_install_success">%s をインストールしました</string> <string name="select_gpu_driver_use_default">デフォルトのGPUドライバーを使用します</string> - <string name="select_gpu_driver_error">選択されたドライバが無効なため、システムのデフォルトを使用します!</string> <string name="system_gpu_driver">システムのGPUドライバ</string> <string name="installing_driver">インストール中…</string> diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml index 427b6e5a0..214f95706 100644 --- a/src/android/app/src/main/res/values-ko/strings.xml +++ b/src/android/app/src/main/res/values-ko/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">저장 데이터 관리</string> <string name="manage_save_data_description">데이터를 저장했습니다. 아래에서 옵션을 선택하세요.</string> <string name="import_export_saves_description">저장 파일 가져오기 또는 내보내기</string> - <string name="import_export_saves_no_profile">저장 데이터를 찾을 수 없습니다. 게임을 실행한 후 다시 시도하세요.</string> <string name="save_file_imported_success">가져오기 성공</string> <string name="save_file_invalid_zip_structure">저장 디렉터리 구조가 잘못됨</string> <string name="save_file_invalid_zip_structure_description">첫 번째 하위 폴더 이름은 게임의 타이틀 ID여야 합니다.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">현재 사용 중인 GPU 드라이버를 교체하겠습니까?</string> <string name="select_gpu_driver_install">설치</string> <string name="select_gpu_driver_default">기본값</string> - <string name="select_gpu_driver_install_success">설치된 %s</string> <string name="select_gpu_driver_use_default">기본 GPU 드라이버 사용</string> - <string name="select_gpu_driver_error">시스템 기본값을 사용하여 잘못된 드라이버를 선택했습니다!</string> <string name="system_gpu_driver">시스템 GPU 드라이버</string> <string name="installing_driver">드라이버 설치 중...</string> diff --git a/src/android/app/src/main/res/values-nb/strings.xml b/src/android/app/src/main/res/values-nb/strings.xml index ce8d7a9e4..5443cef42 100644 --- a/src/android/app/src/main/res/values-nb/strings.xml +++ b/src/android/app/src/main/res/values-nb/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Administrere lagringsdata</string> <string name="manage_save_data_description">Lagringsdata funnet. Velg et alternativ nedenfor.</string> <string name="import_export_saves_description">Importer eller eksporter lagringsfiler</string> - <string name="import_export_saves_no_profile">Ingen lagringsdata funnet. Start et nytt spill og prøv på nytt.</string> <string name="save_file_imported_success">Vellykket import</string> <string name="save_file_invalid_zip_structure">Ugyldig struktur for lagringskatalog</string> <string name="save_file_invalid_zip_structure_description">Det første undermappenavnet må være spillets tittel-ID.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Ønsker du å bytte ut din nåværende GPU-driver?</string> <string name="select_gpu_driver_install">Installer</string> <string name="select_gpu_driver_default">Standard</string> - <string name="select_gpu_driver_install_success">Installert %s</string> <string name="select_gpu_driver_use_default">Bruk av standard GPU-driver</string> - <string name="select_gpu_driver_error">Ugyldig driver valgt, bruker systemstandard!</string> <string name="system_gpu_driver">Systemets GPU-driver</string> <string name="installing_driver">Installerer driver...</string> diff --git a/src/android/app/src/main/res/values-pl/strings.xml b/src/android/app/src/main/res/values-pl/strings.xml index c2c24b48f..899e233d0 100644 --- a/src/android/app/src/main/res/values-pl/strings.xml +++ b/src/android/app/src/main/res/values-pl/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Zarządzaj plikami zapisów gier</string> <string name="manage_save_data_description">Znaleziono pliki zapisów gier. Wybierz opcję poniżej.</string> <string name="import_export_saves_description">Importuj lub wyeksportuj pliki zapisów</string> - <string name="import_export_saves_no_profile">Nie znaleziono plików zapisów. Uruchom grę i spróbuj ponownie.</string> <string name="save_file_imported_success">Zaimportowano pomyślnie</string> <string name="save_file_invalid_zip_structure">Niepoprawna struktura folderów</string> <string name="save_file_invalid_zip_structure_description">Pierwszy podkatalog musi zawierać w nazwie numer ID tytułu gry.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Chcesz zastąpić obecny sterownik układu graficznego?</string> <string name="select_gpu_driver_install">Zainstaluj</string> <string name="select_gpu_driver_default">Domyślne</string> - <string name="select_gpu_driver_install_success">Zainstalowano %s</string> <string name="select_gpu_driver_use_default">Aktywny domyślny sterownik GPU</string> - <string name="select_gpu_driver_error">Wybrano błędny sterownik, powrót do domyślnego. </string> <string name="system_gpu_driver">Systemowy sterownik GPU</string> <string name="installing_driver">Instalowanie sterownika...</string> diff --git a/src/android/app/src/main/res/values-pt-rBR/strings.xml b/src/android/app/src/main/res/values-pt-rBR/strings.xml index 04f276108..caa095364 100644 --- a/src/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/src/android/app/src/main/res/values-pt-rBR/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Gerir dados guardados</string> <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string> <string name="import_export_saves_description">Importa ou exporta dados guardados</string> - <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string> <string name="save_file_imported_success">Importado com sucesso</string> <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string> <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Queres substituir o driver do GPU atual? </string> <string name="select_gpu_driver_install">Instalar</string> <string name="select_gpu_driver_default">Padrão</string> - <string name="select_gpu_driver_install_success">Instalado%s</string> <string name="select_gpu_driver_use_default">Usar o driver padrão do GPU</string> - <string name="select_gpu_driver_error">Driver selecionado inválido, a usar o padrão do sistema!</string> <string name="system_gpu_driver">Driver do GPU padrão</string> <string name="installing_driver">A instalar o Driver...</string> diff --git a/src/android/app/src/main/res/values-pt-rPT/strings.xml b/src/android/app/src/main/res/values-pt-rPT/strings.xml index 66a3a1a2e..0a1a47fbb 100644 --- a/src/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/src/android/app/src/main/res/values-pt-rPT/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Gerir dados guardados</string> <string name="manage_save_data_description">Dados não encontrados. Por favor seleciona uma opção abaixo.</string> <string name="import_export_saves_description">Importa ou exporta dados guardados</string> - <string name="import_export_saves_no_profile">Dados não encontrados. Por favor lança o jogo e tenta novamente.</string> <string name="save_file_imported_success">Importado com sucesso</string> <string name="save_file_invalid_zip_structure">Estrutura de diretório de dados invalida</string> <string name="save_file_invalid_zip_structure_description">O nome da primeira sub pasta tem de ser a ID do jogo.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Queres substituir o driver do GPU atual? </string> <string name="select_gpu_driver_install">Instalar</string> <string name="select_gpu_driver_default">Padrão</string> - <string name="select_gpu_driver_install_success">Instalado%s</string> <string name="select_gpu_driver_use_default">Usar o driver padrão do GPU</string> - <string name="select_gpu_driver_error">Driver selecionado inválido, a usar o padrão do sistema!</string> <string name="system_gpu_driver">Driver do GPU padrão</string> <string name="installing_driver">A instalar o Driver...</string> diff --git a/src/android/app/src/main/res/values-ru/strings.xml b/src/android/app/src/main/res/values-ru/strings.xml index f770e954f..0bef035d6 100644 --- a/src/android/app/src/main/res/values-ru/strings.xml +++ b/src/android/app/src/main/res/values-ru/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Управление данными сохранений</string> <string name="manage_save_data_description">Найдено данные сохранений. Пожалуйста, выберите вариант ниже.</string> <string name="import_export_saves_description">Импорт или экспорт файлов сохранения</string> - <string name="import_export_saves_no_profile">Данные сохранений не найдены. Пожалуйста, запустите игру и повторите попытку.</string> <string name="save_file_imported_success">Успешно импортировано</string> <string name="save_file_invalid_zip_structure">Недопустимая структура папки сохранения</string> <string name="save_file_invalid_zip_structure_description">Название первой вложенной папки должно быть идентификатором игры.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Хотите заменить текущий драйвер ГП?</string> <string name="select_gpu_driver_install">Установить</string> <string name="select_gpu_driver_default">По умолчанию</string> - <string name="select_gpu_driver_install_success">Установлено %s</string> <string name="select_gpu_driver_use_default">Используется стандартный драйвер ГП </string> - <string name="select_gpu_driver_error">Выбран неверный драйвер, используется стандартный системный!</string> <string name="system_gpu_driver">Системный драйвер ГП</string> <string name="installing_driver">Установка драйвера...</string> diff --git a/src/android/app/src/main/res/values-uk/strings.xml b/src/android/app/src/main/res/values-uk/strings.xml index ea3ab1b15..5b789ee98 100644 --- a/src/android/app/src/main/res/values-uk/strings.xml +++ b/src/android/app/src/main/res/values-uk/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">Керування даними збережень</string> <string name="manage_save_data_description">Знайдено дані збережень. Будь ласка, виберіть варіант нижче.</string> <string name="import_export_saves_description">Імпорт або експорт файлів збереження</string> - <string name="import_export_saves_no_profile">Дані збережень не знайдено. Будь ласка, запустіть гру та повторіть спробу.</string> <string name="save_file_imported_success">Успішно імпортовано</string> <string name="save_file_invalid_zip_structure">Неприпустима структура папки збереження</string> <string name="save_file_invalid_zip_structure_description">Назва першої вкладеної папки має бути ідентифікатором гри.</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">Хочете замінити поточний драйвер ГП?</string> <string name="select_gpu_driver_install">Встановити</string> <string name="select_gpu_driver_default">За замовчуванням</string> - <string name="select_gpu_driver_install_success">Встановлено %s</string> <string name="select_gpu_driver_use_default">Використовується стандартний драйвер ГП</string> - <string name="select_gpu_driver_error">Обрано неправильний драйвер, використовується стандартний системний!</string> <string name="system_gpu_driver">Системний драйвер ГП</string> <string name="installing_driver">Встановлення драйвера...</string> diff --git a/src/android/app/src/main/res/values-w600dp/integers.xml b/src/android/app/src/main/res/values-w600dp/integers.xml new file mode 100644 index 000000000..9975db801 --- /dev/null +++ b/src/android/app/src/main/res/values-w600dp/integers.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <integer name="grid_columns">2</integer> + +</resources> diff --git a/src/android/app/src/main/res/values-zh-rCN/strings.xml b/src/android/app/src/main/res/values-zh-rCN/strings.xml index b45a5a528..c0e885751 100644 --- a/src/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/src/android/app/src/main/res/values-zh-rCN/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">管理存档数据</string> <string name="manage_save_data_description">已找到存档数据,请选择下方的选项。</string> <string name="import_export_saves_description">导入或导出存档</string> - <string name="import_export_saves_no_profile">找不到存档数据,请启动游戏并重试。</string> <string name="save_file_imported_success">已成功导入存档</string> <string name="save_file_invalid_zip_structure">无效的存档目录</string> <string name="save_file_invalid_zip_structure_description">第一个子文件夹名称必须为当前游戏的 ID。</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">要取代您当前的 GPU 驱动程序吗?</string> <string name="select_gpu_driver_install">安装</string> <string name="select_gpu_driver_default">系统默认</string> - <string name="select_gpu_driver_install_success">已安装 %s</string> <string name="select_gpu_driver_use_default">使用默认 GPU 驱动程序</string> - <string name="select_gpu_driver_error">选择的驱动程序无效,将使用系统默认的驱动程序!</string> <string name="system_gpu_driver">系统 GPU 驱动程序</string> <string name="installing_driver">正在安装驱动程序…</string> diff --git a/src/android/app/src/main/res/values-zh-rTW/strings.xml b/src/android/app/src/main/res/values-zh-rTW/strings.xml index 3aab889e4..4a21bf893 100644 --- a/src/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/android/app/src/main/res/values-zh-rTW/strings.xml @@ -81,7 +81,6 @@ <string name="manage_save_data">管理儲存資料</string> <string name="manage_save_data_description">已找到儲存資料,請選取下方的選項。</string> <string name="import_export_saves_description">匯入或匯出儲存檔案</string> - <string name="import_export_saves_no_profile">找不到儲存資料,請啟動遊戲並重試。</string> <string name="save_file_imported_success">已成功匯入</string> <string name="save_file_invalid_zip_structure">無效的儲存目錄結構</string> <string name="save_file_invalid_zip_structure_description">首個子資料夾名稱必須為遊戲標題 ID。</string> @@ -172,9 +171,7 @@ <string name="select_gpu_driver_title">要取代您目前的 GPU 驅動程式嗎?</string> <string name="select_gpu_driver_install">安裝</string> <string name="select_gpu_driver_default">預設</string> - <string name="select_gpu_driver_install_success">已安裝 %s</string> <string name="select_gpu_driver_use_default">使用預設 GPU 驅動程式</string> - <string name="select_gpu_driver_error">選取的驅動程式無效,將使用系統預設驅動程式!</string> <string name="system_gpu_driver">系統 GPU 驅動程式</string> <string name="installing_driver">正在安裝驅動程式…</string> diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index 7b2296d95..ef855ea6f 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -13,6 +13,8 @@ <dimen name="menu_width">256dp</dimen> <dimen name="card_width">165dp</dimen> <dimen name="icon_inset">24dp</dimen> + <dimen name="spacing_bottom_list_fab">72dp</dimen> + <dimen name="spacing_fab">24dp</dimen> <dimen name="dialog_margin">20dp</dimen> <dimen name="elevated_app_bar">3dp</dimen> diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml index 5e39bc7d9..dc527965c 100644 --- a/src/android/app/src/main/res/values/integers.xml +++ b/src/android/app/src/main/res/values/integers.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <integer name="game_title_lines">2</integer> + <integer name="grid_columns">1</integer> <!-- Default SWITCH landscape layout --> <integer name="SWITCH_BUTTON_A_X">760</integer> diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index b163e6fc1..9e4854221 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -72,6 +72,7 @@ <string name="invalid_keys_error">Invalid encryption keys</string> <string name="dumping_keys_quickstart_link">https://yuzu-emu.org/help/quickstart/#dumping-decryption-keys</string> <string name="install_keys_failure_description">The selected file is incorrect or corrupt. Please redump your keys.</string> + <string name="gpu_driver_manager">GPU Driver Manager</string> <string name="install_gpu_driver">Install GPU driver</string> <string name="install_gpu_driver_description">Install alternative drivers for potentially better performance or accuracy</string> <string name="advanced_settings">Advanced settings</string> @@ -90,7 +91,6 @@ <string name="manage_save_data">Manage save data</string> <string name="manage_save_data_description">Save data found. Please select an option below.</string> <string name="import_export_saves_description">Import or export save files</string> - <string name="import_export_saves_no_profile">No save data found. Please launch a game and retry.</string> <string name="save_file_imported_success">Imported successfully</string> <string name="save_file_invalid_zip_structure">Invalid save directory structure</string> <string name="save_file_invalid_zip_structure_description">The first subfolder name must be the title ID of the game.</string> @@ -101,12 +101,13 @@ <string name="firmware_installing">Installing firmware</string> <string name="firmware_installed_success">Firmware installed successfully</string> <string name="firmware_installed_failure">Firmware installation failed</string> - <string name="firmware_installed_failure_description">Verify that the ZIP contains valid firmware and try again.</string> + <string name="firmware_installed_failure_description">Make sure the firmware nca files are at the root of the zip and try again.</string> <string name="share_log">Share debug logs</string> <string name="share_log_description">Share yuzu\'s log file to debug issues</string> <string name="share_log_missing">No log file found</string> <string name="install_game_content">Install game content</string> <string name="install_game_content_description">Install game updates or DLC</string> + <string name="installing_game_content">Installing content…</string> <string name="install_game_content_failure">Error installing file(s) to NAND</string> <string name="install_game_content_failure_description">Please ensure content(s) are valid and that the prod.keys file is installed.</string> <string name="install_game_content_failure_base">Installation of base games isn\'t permitted in order to avoid possible conflicts.</string> @@ -118,6 +119,10 @@ <string name="install_game_content_help_link">https://yuzu-emu.org/help/quickstart/#dumping-installed-updates</string> <string name="custom_driver_not_supported">Custom drivers not supported</string> <string name="custom_driver_not_supported_description">Custom driver loading isn\'t currently supported for this device.\nCheck this option again in the future to see if support was added!</string> + <string name="manage_yuzu_data">Manage yuzu data</string> + <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string> + <string name="share_save_file">Share save file</string> + <string name="export_save_failed">Failed to export save</string> <!-- About screen strings --> <string name="gaia_is_not_real">Gaia isn\'t real</string> @@ -128,6 +133,16 @@ <string name="contributors_link">https://github.com/yuzu-emu/yuzu/graphs/contributors</string> <string name="licenses_description">Projects that make yuzu for Android possible</string> <string name="build">Build</string> + <string name="user_data">User data</string> + <string name="user_data_description">Import/export all app data.\n\nWhen importing user data, all existing user data will be deleted!</string> + <string name="exporting_user_data">Exporting user data…</string> + <string name="importing_user_data">Importing user data…</string> + <string name="import_user_data">Import user data</string> + <string name="invalid_yuzu_backup">Invalid yuzu backup</string> + <string name="user_data_export_success">User data exported successfully</string> + <string name="user_data_import_success">User data imported successfully</string> + <string name="user_data_export_cancelled">Export cancelled</string> + <string name="user_data_import_failed_description">Make sure the user data folders are at the root of the zip folder and contain a config file at config/config.ini and try again.</string> <string name="support_link">https://discord.gg/u77vRWY</string> <string name="website_link">https://yuzu-emu.org/</string> <string name="github_link">https://github.com/yuzu-emu</string> @@ -215,15 +230,22 @@ <string name="auto">Auto</string> <string name="submit">Submit</string> <string name="string_null">Null</string> + <string name="string_import">Import</string> + <string name="export">Export</string> + <string name="export_failed">Export failed</string> + <string name="import_failed">Import failed</string> + <string name="cancelling">Cancelling</string> + <string name="install">Install</string> + <string name="delete">Delete</string> <!-- GPU driver installation --> <string name="select_gpu_driver">Select GPU driver</string> <string name="select_gpu_driver_title">Would you like to replace your current GPU driver?</string> <string name="select_gpu_driver_install">Install</string> <string name="select_gpu_driver_default">Default</string> - <string name="select_gpu_driver_install_success">Installed %s</string> <string name="select_gpu_driver_use_default">Using default GPU driver</string> - <string name="select_gpu_driver_error">Invalid driver selected, using system default!</string> + <string name="select_gpu_driver_error">Invalid driver selected</string> + <string name="driver_already_installed">Driver already installed</string> <string name="system_gpu_driver">System GPU driver</string> <string name="installing_driver">Installing driver…</string> @@ -281,6 +303,7 @@ <string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string> <string name="device_memory_inadequate">Device RAM: %1$s\nRecommended: %2$s</string> <string name="memory_formatted">%1$s %2$s</string> + <string name="no_game_present">No bootable game present!</string> <!-- Region Names --> <string name="region_japan">Japan</string> diff --git a/src/android/build.gradle.kts b/src/android/build.gradle.kts index 80f370c16..51e559321 100644 --- a/src/android/build.gradle.kts +++ b/src/android/build.gradle.kts @@ -3,8 +3,8 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.0.2" apply false - id("com.android.library") version "8.0.2" apply false + id("com.android.application") version "8.1.2" apply false + id("com.android.library") version "8.1.2" apply false id("org.jetbrains.kotlin.android") version "1.8.21" apply false } |