android: Add support for compressing and decompressing files (#1458)

This commit is contained in:
Kleidis 2025-12-31 15:38:22 +01:00 committed by GitHub
parent 40d512cafd
commit f31bd1bcdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 532 additions and 13 deletions

View File

@ -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 =

View File

@ -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<String>?) :
class GameAdapter(
private val activity: AppCompatActivity,
private val inflater: LayoutInflater,
private val openImageLauncher: ActivityResultLauncher<String>?,
private val onRequestCompressOrDecompress: ((inputPath: String, suggestedName: String, shouldCompress: Boolean) -> Unit)? = null
) :
ListAdapter<Game, GameViewHolder>(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<MaterialButton>(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<MaterialButton>(R.id.menu_button_open).setOnClickListener {
showOpenContextMenu(it, game)
}

View File

@ -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<android.widget.TextView>(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
}
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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"

View File

@ -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 {

View File

@ -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 = ""
}
}

View File

@ -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<VideoCore::LoadCallbackStage, jobject> 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) {

View File

@ -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();

View File

@ -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<Common::DynamicLibrary> vulkan_library{};
std::unique_ptr<EmuWindow_Android> window;
std::unique_ptr<EmuWindow_Android> 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<u8, 4>({'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<u8> 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<jint>(stat);
}
auto progress = [](std::size_t processed, std::size_t total) {
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
IDCache::GetCompressProgressMethod(), static_cast<jlong>(total),
static_cast<jlong>(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<jint>(CompressionStatus::Compress_Failed);
}
return static_cast<jint>(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<jint>(stat);
}
auto progress = [](std::size_t processed, std::size_t total) {
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
IDCache::GetCompressProgressMethod(), static_cast<jlong>(total),
static_cast<jlong>(processed));
};
bool success = FileUtil::DeCompressZ3DSFile(input_path, output_path, progress);
if (!success) {
FileUtil::Delete(output_path);
return static_cast<jint>(CompressionStatus::Decompress_Failed);
}
return static_cast<jint>(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) {

View File

@ -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" />
</LinearLayout>
<LinearLayout
android:id="@+id/compress_tray"
style="@style/ThemeOverlay.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="start|center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/game_button_tray">
<com.google.android.material.button.MaterialButton
android:id="@+id/compress_decompress"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/compress"
android:text="@string/compress" />
</LinearLayout>

View File

@ -0,0 +1,27 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/compress_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="visible"
android:paddingBottom="8dp" />
<ProgressBar
android:id="@+id/compress_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="48dp"
android:indeterminate="false"
android:max="100"
android:progress="0"
android:layout_marginTop="8dp" />
</LinearLayout>

View File

@ -912,4 +912,19 @@
<string name="emulation_occupied_quicksave_slot">Quicksave - %1$tF %1$tR</string>
<string name="quickload_not_found">No Quicksave available.</string>
<!-- File Compression -->
<string name="compress">Compress</string>
<string name="compressing">Compressing…</string>
<string name="decompress">Decompress</string>
<string name="decompressing">Decompressing…</string>
<string name="compress_success">Compression completed successfully.</string>
<string name="compress_unsupported">Compression not supported for this file.</string>
<string name="compress_already">File is already compressed.</string>
<string name="compress_failed">Compression failed.</string>
<string name="decompress_success">Decompression completed successfully.</string>
<string name="decompress_unsupported">Decompression not supported for this file.</string>
<string name="decompress_not_compressed">File is not compressed.</string>
<string name="decompress_failed">Decompression failed.</string>
<string name="compress_decompress_installed_app">Already installed applications cannot be compressed or decompressed.</string>
</resources>

View File

@ -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;

View File

@ -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 {