From f31bd1bcdd615bf8013e5f3bc53313a608362230 Mon Sep 17 00:00:00 2001 From: Kleidis Date: Wed, 31 Dec 2025 15:38:22 +0100 Subject: [PATCH] android: Add support for compressing and decompressing files (#1458) --- .../java/org/citra/citra_emu/NativeLibrary.kt | 42 +++++ .../citra/citra_emu/adapters/GameAdapter.kt | 28 ++- .../CompressProgressDialogFragment.kt | 89 +++++++++ .../citra_emu/fragments/GamesFragment.kt | 62 ++++++- .../citra_emu/fragments/SearchFragment.kt | 24 ++- .../java/org/citra/citra_emu/model/Game.kt | 1 + .../org/citra/citra_emu/utils/GameHelper.kt | 1 + .../CompressProgressDialogViewModel.kt | 33 ++++ src/android/app/src/main/jni/id_cache.cpp | 9 +- src/android/app/src/main/jni/id_cache.h | 3 +- src/android/app/src/main/jni/native.cpp | 170 ++++++++++++++++++ .../src/main/res/layout/dialog_about_game.xml | 27 ++- .../res/layout/dialog_compress_progress.xml | 27 +++ .../app/src/main/res/values/strings.xml | 15 ++ src/core/hle/service/fs/archive.cpp | 11 +- src/core/hle/service/fs/archive.h | 3 +- 16 files changed, 532 insertions(+), 13 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CompressProgressDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/CompressProgressDialogViewModel.kt create mode 100644 src/android/app/src/main/res/layout/dialog_compress_progress.xml diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt index bce4ac490..f62510098 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt @@ -28,6 +28,7 @@ import org.citra.citra_emu.activities.EmulationActivity import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.Log import org.citra.citra_emu.utils.RemovableStorageHelper +import org.citra.citra_emu.viewmodel.CompressProgressDialogViewModel import java.lang.ref.WeakReference import java.util.Date @@ -600,6 +601,47 @@ object NativeLibrary { */ external fun logDeviceInfo() + enum class CompressStatus(val value: Int) { + SUCCESS(0), + COMPRESS_UNSUPPORTED(1), + COMPRESS_ALREADY_COMPRESSED(2), + COMPRESS_FAILED(3), + DECOMPRESS_UNSUPPORTED(4), + DECOMPRESS_NOT_COMPRESSED(5), + DECOMPRESS_FAILED(6), + INSTALLED_APPLICATION(7); + + companion object { + fun fromValue(value: Int): CompressStatus = + CompressStatus.entries.first { it.value == value } + } + } + + // Compression / Decompression + private external fun compressFileNative(inputPath: String?, outputPath: String): Int + + fun compressFile(inputPath: String?, outputPath: String): CompressStatus { + return CompressStatus.fromValue( + compressFileNative(inputPath, outputPath) + ) + } + + private external fun decompressFileNative(inputPath: String?, outputPath: String): Int + + fun decompressFile(inputPath: String?, outputPath: String): CompressStatus { + return CompressStatus.fromValue( + decompressFileNative(inputPath, outputPath) + ) + } + + external fun getRecommendedExtension(inputPath: String?, shouldCompress: Boolean): String + + @Keep + @JvmStatic + fun onCompressProgress(total: Long, current: Long) { + CompressProgressDialogViewModel.update(total, current) + } + @Keep @JvmStatic fun createFile(directory: String, filename: String): Boolean = diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index d03ff2936..a92f93401 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -57,7 +57,12 @@ import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel -class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater, private val openImageLauncher: ActivityResultLauncher?) : +class GameAdapter( + private val activity: AppCompatActivity, + private val inflater: LayoutInflater, + private val openImageLauncher: ActivityResultLauncher?, + private val onRequestCompressOrDecompress: ((inputPath: String, suggestedName: String, shouldCompress: Boolean) -> Unit)? = null +) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), View.OnClickListener, View.OnLongClickListener { private var lastClickTime = 0L @@ -441,6 +446,27 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: bottomSheetDialog.dismiss() } + val compressDecompressButton = bottomSheetView.findViewById(R.id.compress_decompress) + if (game.isInstalled) { + compressDecompressButton.setOnClickListener { + Toast.makeText( + context, + context.getString(R.string.compress_decompress_installed_app), + Toast.LENGTH_LONG + ).show() + } + compressDecompressButton.alpha = 0.38f + } else { + compressDecompressButton.setOnClickListener { + val shouldCompress = !game.isCompressed + val recommendedExt = NativeLibrary.getRecommendedExtension(holder.game.path, shouldCompress) + val baseName = holder.game.filename.substringBeforeLast('.') + onRequestCompressOrDecompress?.invoke(holder.game.path, "$baseName.$recommendedExt", shouldCompress) + bottomSheetDialog.dismiss() + } + } + compressDecompressButton.text = context.getString(if (!game.isCompressed) R.string.compress else R.string.decompress) + bottomSheetView.findViewById(R.id.menu_button_open).setOnClickListener { showOpenContextMenu(it, game) } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CompressProgressDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CompressProgressDialogFragment.kt new file mode 100644 index 000000000..bcd97ae03 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CompressProgressDialogFragment.kt @@ -0,0 +1,89 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import android.widget.ProgressBar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.Lifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.viewmodel.CompressProgressDialogViewModel +import org.citra.citra_emu.NativeLibrary + +class CompressProgressDialogFragment : DialogFragment() { + private lateinit var progressBar: ProgressBar + private var outputPath: String? = null + private var isCompressing: Boolean = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + isCompressing = it.getBoolean(ARG_IS_COMPRESSING, true) + outputPath = it.getString(ARG_OUTPUT_PATH) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = layoutInflater.inflate(R.layout.dialog_compress_progress, null) + progressBar = view.findViewById(R.id.compress_progress) + val label = view.findViewById(R.id.compress_label) + label.text = if (isCompressing) getString(R.string.compressing) else getString(R.string.decompressing) + + isCancelable = false + progressBar.isIndeterminate = true + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + combine(CompressProgressDialogViewModel.total, CompressProgressDialogViewModel.progress) { total, progress -> + total to progress + }.collectLatest { (total, progress) -> + if (total <= 0) { + progressBar.isIndeterminate = true + label.visibility = View.GONE + } else { + progressBar.isIndeterminate = false + label.visibility = View.VISIBLE + progressBar.max = total + progressBar.setProgress(progress, true) + } + } + } + } + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setView(view) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel) { _: android.content.DialogInterface, _: Int -> + outputPath?.let { path -> + NativeLibrary.deleteDocument(path) + } + } + + return builder.show() + } + + companion object { + const val TAG = "CompressProgressDialog" + private const val ARG_IS_COMPRESSING = "isCompressing" + private const val ARG_OUTPUT_PATH = "outputPath" + + fun newInstance(isCompressing: Boolean, outputPath: String?): CompressProgressDialogFragment { + val frag = CompressProgressDialogFragment() + val args = Bundle() + args.putBoolean(ARG_IS_COMPRESSING, isCompressing) + args.putString(ARG_OUTPUT_PATH, outputPath) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt index b224c5c15..9ade73c83 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt @@ -30,14 +30,17 @@ import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.transition.MaterialFadeThrough +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.adapters.GameAdapter import org.citra.citra_emu.databinding.FragmentGamesBinding import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.model.Game +import org.citra.citra_emu.viewmodel.CompressProgressDialogViewModel import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel @@ -56,6 +59,58 @@ class GamesFragment : Fragment() { gameAdapter.handleShortcutImageResult(uri) } + private var shouldCompress: Boolean = true + private var pendingCompressInvocation: String? = null + + companion object { + fun doCompression(fragment: Fragment, gamesViewModel: GamesViewModel, inputPath: String?, outputUri: Uri?, shouldCompress: Boolean) { + if (outputUri != null) { + CompressProgressDialogViewModel.reset() + val dialog = CompressProgressDialogFragment.newInstance(shouldCompress, outputUri.toString()) + dialog.showNow( + fragment.requireActivity().supportFragmentManager, + CompressProgressDialogFragment.TAG + ) + + fragment.lifecycleScope.launch(Dispatchers.IO) { + val status = if (shouldCompress) { + NativeLibrary.compressFile(inputPath, outputUri.toString()) + } else { + NativeLibrary.decompressFile(inputPath, outputUri.toString()) + } + + fragment.requireActivity().runOnUiThread { + dialog.dismiss() + val resId = when (status) { + NativeLibrary.CompressStatus.SUCCESS -> if (shouldCompress) R.string.compress_success else R.string.decompress_success + NativeLibrary.CompressStatus.COMPRESS_UNSUPPORTED -> R.string.compress_unsupported + NativeLibrary.CompressStatus.COMPRESS_ALREADY_COMPRESSED -> R.string.compress_already + NativeLibrary.CompressStatus.COMPRESS_FAILED -> R.string.compress_failed + NativeLibrary.CompressStatus.DECOMPRESS_UNSUPPORTED -> R.string.decompress_unsupported + NativeLibrary.CompressStatus.DECOMPRESS_NOT_COMPRESSED -> R.string.decompress_not_compressed + NativeLibrary.CompressStatus.DECOMPRESS_FAILED -> R.string.decompress_failed + NativeLibrary.CompressStatus.INSTALLED_APPLICATION -> R.string.compress_decompress_installed_app + } + + MaterialAlertDialogBuilder(fragment.requireContext()) + .setMessage(fragment.getString(resId)) + .setPositiveButton(android.R.string.ok, null) + .show() + + gamesViewModel.reloadGames(false) + } + } + } + } + } + + private val onCompressDecompressLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + doCompression(this, gamesViewModel, pendingCompressInvocation, uri, shouldCompress) + pendingCompressInvocation = null + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialFadeThrough() @@ -81,7 +136,12 @@ class GamesFragment : Fragment() { gameAdapter = GameAdapter( requireActivity() as AppCompatActivity, inflater, - openImageLauncher + openImageLauncher, + onRequestCompressOrDecompress = { inputPath, suggestedName, shouldCompress -> + pendingCompressInvocation = inputPath + onCompressDecompressLauncher.launch(suggestedName) + this.shouldCompress = shouldCompress + } ) binding.gridGames.apply { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt index 94821023f..dab5ea745 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt @@ -7,11 +7,13 @@ package org.citra.citra_emu.fragments import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -26,18 +28,19 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import info.debatty.java.stringsimilarity.Jaccard import info.debatty.java.stringsimilarity.JaroWinkler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.R +import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.adapters.GameAdapter import org.citra.citra_emu.databinding.FragmentSearchBinding import org.citra.citra_emu.model.Game +import org.citra.citra_emu.viewmodel.CompressProgressDialogViewModel import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel import java.time.temporal.ChronoField import java.util.Locale -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContracts class SearchFragment : Fragment() { private var _binding: FragmentSearchBinding? = null @@ -53,6 +56,15 @@ class SearchFragment : Fragment() { gameAdapter.handleShortcutImageResult(uri) } + private var shouldCompress: Boolean = true + private var pendingCompressInvocation: String? = null + private val onCompressDecompressLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + GamesFragment.doCompression(this, gamesViewModel, pendingCompressInvocation, uri, shouldCompress) + pendingCompressInvocation = null + } + private lateinit var preferences: SharedPreferences companion object { @@ -85,7 +97,13 @@ class SearchFragment : Fragment() { gameAdapter = GameAdapter( requireActivity() as AppCompatActivity, inflater, - openImageLauncher + openImageLauncher, + onRequestCompressOrDecompress = { inputPath, suggestedName, shouldCompress -> + pendingCompressInvocation = inputPath + onCompressDecompressLauncher.launch(suggestedName) + this.shouldCompress = shouldCompress + } + ) binding.gridGamesSearch.apply { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt index 9ff7600ec..b74635e96 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt @@ -27,6 +27,7 @@ class Game( val isVisibleSystemTitle: Boolean = false, val icon: IntArray? = null, val fileType: String = "", + val isCompressed: Boolean = false, val filename: String, ) : Parcelable { val keyAddedToLibraryTime get() = "${filename}_AddedToLibraryTime" diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt index ffbeaf394..7b7ca9fdb 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt @@ -90,6 +90,7 @@ object GameHelper { gameInfo?.getIsVisibleSystemTitle() ?: false, gameInfo?.getIcon(), gameInfo?.getFileType() ?: "", + gameInfo?.getFileType()?.contains("(Z)") ?: false, if (FileUtil.isNativePath(filePath)) { CitraApplication.documentsTree.getFilename(filePath) } else { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/CompressProgressDialogViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/CompressProgressDialogViewModel.kt new file mode 100644 index 000000000..7a6e71e52 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/CompressProgressDialogViewModel.kt @@ -0,0 +1,33 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +object CompressProgressDialogViewModel: ViewModel() { + private val _progress = MutableStateFlow(0) + val progress = _progress.asStateFlow() + + private val _total = MutableStateFlow(0) + val total = _total.asStateFlow() + + private val _message = MutableStateFlow("") + val message = _message.asStateFlow() + + fun update(totalBytes: Long, currentBytes: Long) { + val percent = ((currentBytes * 100L) / totalBytes).coerceIn(0L, 100L).toInt() + _total.value = 100 + _progress.value = percent + _message.value = "" + } + + fun reset() { + _progress.value = 0 + _total.value = 0 + _message.value = "" + } +} \ No newline at end of file diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index 8bc0f976e..d7d4a109a 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -1,4 +1,4 @@ -// Copyright 2019 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -40,6 +40,7 @@ static jfieldID s_game_info_pointer; static jclass s_disk_cache_progress_class; static jmethodID s_disk_cache_load_progress; +static jmethodID s_compress_progress_method; static std::unordered_map s_java_load_callback_stages; static jclass s_cia_install_helper_class; @@ -131,6 +132,10 @@ jmethodID GetDiskCacheLoadProgress() { return s_disk_cache_load_progress; } +jmethodID GetCompressProgressMethod() { + return s_compress_progress_method; +} + jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage) { const auto it = s_java_load_callback_stages.find(stage); ASSERT_MSG(it != s_java_load_callback_stages.end(), "Invalid LoadCallbackStage: {}", stage); @@ -205,6 +210,8 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { s_disk_cache_load_progress = env->GetStaticMethodID( s_disk_cache_progress_class, "loadProgress", "(Lorg/citra/citra_emu/utils/DiskShaderCacheProgress$LoadCallbackStage;II)V"); + s_compress_progress_method = + env->GetStaticMethodID(s_native_library_class, "onCompressProgress", "(JJ)V"); // Initialize LoadCallbackStage map const auto to_java_load_callback_stage = [env, load_callback_stage_class](const std::string& stage) { diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h index 71a1cb67c..d7aeb8074 100644 --- a/src/android/app/src/main/jni/id_cache.h +++ b/src/android/app/src/main/jni/id_cache.h @@ -1,4 +1,4 @@ -// Copyright 2019 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -40,6 +40,7 @@ jfieldID GetGameInfoPointer(); jclass GetDiskCacheProgressClass(); jmethodID GetDiskCacheLoadProgress(); +jmethodID GetCompressProgressMethod(); jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage); jclass GetCiaInstallHelperClass(); diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index e52c09a65..a0dc4d864 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -34,10 +34,12 @@ #include "common/scope_exit.h" #include "common/settings.h" #include "common/string_util.h" +#include "common/zstd_compression.h" #include "core/core.h" #include "core/frontend/applets/default_applets.h" #include "core/frontend/camera/factory.h" #include "core/hle/service/am/am.h" +#include "core/hle/service/fs/archive.h" #include "core/hle/service/nfc/nfc.h" #include "core/hw/unique_data.h" #include "core/loader/loader.h" @@ -74,6 +76,17 @@ namespace { ANativeWindow* s_surface; ANativeWindow* s_secondary_surface; +enum class CompressionStatus : jint { + Success = 0, + Compress_Unsupported = 1, + Compress_AlreadyCompressed = 2, + Compress_Failed = 3, + Decompress_Unsupported = 4, + Decompress_NotCompressed = 5, + Decompress_Failed = 6, + Installed_Application = 7, +}; + std::shared_ptr vulkan_library{}; std::unique_ptr window; std::unique_ptr secondary_window; @@ -464,6 +477,163 @@ jstring Java_org_citra_citra_1emu_NativeLibrary_getHomeMenuPath(JNIEnv* env, return ToJString(env, ""); } +static CompressionStatus GetCompressFileInfo(Loader::AppLoader::CompressFileInfo& out_info, + size_t& out_frame_size, const std::string& filepath, + bool compress) { + + if (Service::FS::IsInstalledApplication(filepath)) { + return CompressionStatus::Installed_Application; + } + + Loader::AppLoader::CompressFileInfo compress_info{}; + compress_info.is_supported = false; + size_t frame_size{}; + auto loader = Loader::GetLoader(filepath); + if (loader) { + compress_info = loader->GetCompressFileInfo(); + frame_size = FileUtil::Z3DSWriteIOFile::DEFAULT_FRAME_SIZE; + } else { + bool is_compressed = false; + if (Service::AM::CheckCIAToInstall(filepath, is_compressed, compress ? true : false) == + Service::AM::InstallStatus::Success) { + compress_info.is_supported = true; + compress_info.is_compressed = is_compressed; + compress_info.recommended_compressed_extension = "zcia"; + compress_info.recommended_uncompressed_extension = "cia"; + compress_info.underlying_magic = std::array({'C', 'I', 'A', '\0'}); + frame_size = FileUtil::Z3DSWriteIOFile::DEFAULT_CIA_FRAME_SIZE; + if (compress) { + auto meta_info = Service::AM::GetCIAInfos(filepath); + if (meta_info.Succeeded()) { + const auto& meta_info_val = meta_info.Unwrap(); + std::vector value(sizeof(Service::AM::TitleInfo)); + memcpy(value.data(), &meta_info_val.first, sizeof(Service::AM::TitleInfo)); + compress_info.default_metadata.emplace("titleinfo", value); + if (meta_info_val.second) { + value.resize(sizeof(Loader::SMDH)); + memcpy(value.data(), meta_info_val.second.get(), sizeof(Loader::SMDH)); + compress_info.default_metadata.emplace("smdh", value); + } + } + } + } + } + + if (!compress_info.is_supported) { + LOG_ERROR(Frontend, + "Error {} file {}, the selected file is not a compatible 3DS ROM format or is " + "encrypted.", + compress ? "compressing" : "decompressing", filepath); + return compress ? CompressionStatus::Compress_Unsupported + : CompressionStatus::Decompress_Unsupported; + } + if (compress_info.is_compressed && compress) { + LOG_ERROR(Frontend, "Error compressing file {}, the selected file is already compressed", + filepath); + return CompressionStatus::Compress_AlreadyCompressed; + } + if (!compress_info.is_compressed && !compress) { + LOG_ERROR(Frontend, + "Error decompressing file {}, the selected file is already decompressed", + filepath); + return CompressionStatus::Decompress_NotCompressed; + } + + out_info = compress_info; + out_frame_size = frame_size; + return CompressionStatus::Success; +} + +jint Java_org_citra_citra_1emu_NativeLibrary_compressFileNative(JNIEnv* env, jobject obj, + jstring j_input_path, + jstring j_output_path) { + const std::string input_path = GetJString(env, j_input_path); + const std::string output_path = GetJString(env, j_output_path); + + Loader::AppLoader::CompressFileInfo compress_info{}; + size_t frame_size{}; + CompressionStatus stat = GetCompressFileInfo(compress_info, frame_size, input_path, true); + if (stat != CompressionStatus::Success) { + return static_cast(stat); + } + + auto progress = [](std::size_t processed, std::size_t total) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetCompressProgressMethod(), static_cast(total), + static_cast(processed)); + }; + + bool success = + FileUtil::CompressZ3DSFile(input_path, output_path, compress_info.underlying_magic, + frame_size, progress, compress_info.default_metadata); + if (!success) { + FileUtil::Delete(output_path); + return static_cast(CompressionStatus::Compress_Failed); + } + + return static_cast(CompressionStatus::Success); +} + +jint Java_org_citra_citra_1emu_NativeLibrary_decompressFileNative(JNIEnv* env, jobject obj, + jstring j_input_path, + jstring j_output_path) { + const std::string input_path = GetJString(env, j_input_path); + const std::string output_path = GetJString(env, j_output_path); + + Loader::AppLoader::CompressFileInfo compress_info{}; + size_t frame_size{}; + CompressionStatus stat = GetCompressFileInfo(compress_info, frame_size, input_path, false); + if (stat != CompressionStatus::Success) { + return static_cast(stat); + } + + auto progress = [](std::size_t processed, std::size_t total) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetCompressProgressMethod(), static_cast(total), + static_cast(processed)); + }; + + bool success = FileUtil::DeCompressZ3DSFile(input_path, output_path, progress); + if (!success) { + FileUtil::Delete(output_path); + return static_cast(CompressionStatus::Decompress_Failed); + } + + return static_cast(CompressionStatus::Success); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_getRecommendedExtension( + JNIEnv* env, jobject obj, jstring j_input_path, jboolean j_should_compress) { + const std::string input_path = GetJString(env, j_input_path); + + std::string compressed_ext; + std::string uncompressed_ext; + + auto loader = Loader::GetLoader(input_path); + if (loader) { + auto compress_info = loader->GetCompressFileInfo(); + if (compress_info.is_supported) { + compressed_ext = compress_info.recommended_compressed_extension; + uncompressed_ext = compress_info.recommended_uncompressed_extension; + } + } else { + bool is_compressed = false; + if (Service::AM::CheckCIAToInstall(input_path, is_compressed, true) == + Service::AM::InstallStatus::Success) { + compressed_ext = "zcia"; + uncompressed_ext = "cia"; + } + } + + if (compressed_ext.empty()) { + return env->NewStringUTF(""); + } + + return env->NewStringUTF(j_should_compress ? compressed_ext.c_str() : uncompressed_ext.c_str()); +} + void Java_org_citra_citra_1emu_NativeLibrary_setUserDirectory(JNIEnv* env, [[maybe_unused]] jobject obj, jstring j_directory) { diff --git a/src/android/app/src/main/res/layout/dialog_about_game.xml b/src/android/app/src/main/res/layout/dialog_about_game.xml index feb95cf2d..2ba1e3473 100644 --- a/src/android/app/src/main/res/layout/dialog_about_game.xml +++ b/src/android/app/src/main/res/layout/dialog_about_game.xml @@ -45,7 +45,8 @@ android:layout_height="wrap_content" android:textAlignment="viewStart" android:textSize="15sp" - android:textStyle="bold" app:layout_constraintStart_toStartOf="parent" + android:textStyle="bold" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Application Title" /> @@ -166,7 +167,6 @@ android:layout_marginTop="16dp" android:gravity="start|center" android:orientation="horizontal" - app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/horizontal_layout"> @@ -179,6 +179,29 @@ android:contentDescription="@string/cheats" android:text="@string/cheats" /> + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_compress_progress.xml b/src/android/app/src/main/res/layout/dialog_compress_progress.xml new file mode 100644 index 000000000..bdf8dd669 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_compress_progress.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 11c4943c8..e861035d5 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -912,4 +912,19 @@ Quicksave - %1$tF %1$tR No Quicksave available. + + Compress + Compressing… + Decompress + Decompressing… + Compression completed successfully. + Compression not supported for this file. + File is already compressed. + Compression failed. + Decompression completed successfully. + Decompression not supported for this file. + File is not compressed. + Decompression failed. + Already installed applications cannot be compressed or decompressed. + diff --git a/src/core/hle/service/fs/archive.cpp b/src/core/hle/service/fs/archive.cpp index 2a48a741a..131cb58c0 100644 --- a/src/core/hle/service/fs/archive.cpp +++ b/src/core/hle/service/fs/archive.cpp @@ -1,4 +1,4 @@ -// Copyright 2014 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -30,11 +30,16 @@ namespace Service::FS { +bool IsInstalledApplication(std::string_view path) { + return path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::NANDDir) + "title", 0) == 0 || + path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir) + "Nintendo 3DS", 0) == 0; +} + MediaType GetMediaTypeFromPath(std::string_view path) { - if (path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::NANDDir), 0) == 0) { + if (path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::NANDDir) + "title", 0) == 0) { return MediaType::NAND; } - if (path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir), 0) == 0) { + if (path.rfind(FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir) + "Nintendo 3DS", 0) == 0) { return MediaType::SDMC; } return MediaType::GameCard; diff --git a/src/core/hle/service/fs/archive.h b/src/core/hle/service/fs/archive.h index 387b11dba..2e017f3e0 100644 --- a/src/core/hle/service/fs/archive.h +++ b/src/core/hle/service/fs/archive.h @@ -1,4 +1,4 @@ -// Copyright 2014 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -51,6 +51,7 @@ enum class ArchiveIdCode : u32 { /// Media types for the archives enum class MediaType : u32 { NAND = 0, SDMC = 1, GameCard = 2 }; +bool IsInstalledApplication(std::string_view path); MediaType GetMediaTypeFromPath(std::string_view path); enum class SpecialContentType : u8 {