From f5bb17c82e737550c68f772db26c0f7efffef67f Mon Sep 17 00:00:00 2001 From: SachinVin Date: Mon, 1 May 2023 17:51:01 +0530 Subject: [PATCH] Android: Offload CIA installation to background thread --- src/android/app/build.gradle | 1 + .../org/citra/citra_emu/CitraApplication.java | 27 ++- .../org/citra/citra_emu/NativeLibrary.java | 2 - .../citra/citra_emu/ui/main/MainActivity.java | 28 ++- .../citra_emu/utils/CiaInstallWorker.java | 167 ++++++++++++++++++ src/android/app/src/main/jni/id_cache.cpp | 68 ++++++- src/android/app/src/main/jni/id_cache.h | 8 + src/android/app/src/main/jni/native.cpp | 33 ++-- src/android/app/src/main/jni/native.h | 8 +- .../app/src/main/res/values/strings.xml | 18 ++ 10 files changed, 313 insertions(+), 47 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index 803f8562c..53d67aa00 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -129,6 +129,7 @@ dependencies { implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" implementation 'com.google.android.material:material:1.6.1' implementation 'androidx.core:core-splashscreen:1.0.0' + implementation "androidx.work:work-runtime:2.8.1" // For loading huge screenshots from the disk. implementation 'com.squareup.picasso:picasso:2.71828' diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java index 638e1ebaa..b57226070 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java @@ -23,16 +23,33 @@ public class CitraApplication extends Application { private void createNotificationChannel() { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager notificationManager = getSystemService(NotificationManager.class); + { + // General notification CharSequence name = getString(R.string.app_notification_channel_name); String description = getString(R.string.app_notification_channel_description); - NotificationChannel channel = new NotificationChannel(getString(R.string.app_notification_channel_id), name, NotificationManager.IMPORTANCE_LOW); + NotificationChannel channel = new NotificationChannel( + getString(R.string.app_notification_channel_id), name, + NotificationManager.IMPORTANCE_LOW); channel.setDescription(description); channel.setSound(null, null); channel.setVibrationPattern(null); - // Register the channel with the system; you can't change the importance - // or other notification behaviors after this - NotificationManager notificationManager = getSystemService(NotificationManager.class); + + notificationManager.createNotificationChannel(channel); + } + { + // CIA Install notifications + NotificationChannel channel = new NotificationChannel( + getString(R.string.cia_install_notification_channel_id), + getString(R.string.cia_install_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT); + channel.setDescription(getString(R.string.cia_install_notification_channel_description)); + channel.setSound(null, null); + channel.setVibrationPattern(null); + notificationManager.createNotificationChannel(channel); } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java index 4af8b5c8f..91c9f5be7 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java @@ -589,8 +589,6 @@ public final class NativeLibrary { public static native void RemoveAmiibo(); - public static native void InstallCIAS(String[] path); - public static final int SAVESTATE_SLOT_COUNT = 10; public static final class SavestateInfo { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java index c9b28daf9..68440f916 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -20,10 +20,15 @@ import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.work.Data; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; import com.google.android.material.appbar.AppBarLayout; -import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.R; import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.contracts.OpenFileResultContract; @@ -32,6 +37,7 @@ import org.citra.citra_emu.model.GameProvider; import org.citra.citra_emu.ui.platform.PlatformGamesFragment; import org.citra.citra_emu.utils.AddDirectoryHelper; import org.citra.citra_emu.utils.BillingManager; +import org.citra.citra_emu.utils.CiaInstallWorker; import org.citra.citra_emu.utils.CitraDirectoryHelper; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.FileBrowserHelper; @@ -50,7 +56,9 @@ public final class MainActivity extends AppCompatActivity implements MainView { private int mFrameLayoutId; private PlatformGamesFragment mPlatformGamesFragment; - private MainPresenter mPresenter = new MainPresenter(this); + private final MainPresenter mPresenter = new MainPresenter(this); + + // private final CiaInstallWorker mCiaInstallWorker = new CiaInstallWorker(); // Singleton to manage user billing state private static BillingManager mBillingManager; @@ -91,7 +99,7 @@ public final class MainActivity extends AppCompatActivity implements MainView { mPresenter.onDirectorySelected(result.toString()); }); - private final ActivityResultLauncher mOpenFileLauncher = + private final ActivityResultLauncher mInstallCiaFileLauncher = registerForActivityResult(new OpenFileResultContract(), result -> { if (result == null) return; @@ -104,8 +112,16 @@ public final class MainActivity extends AppCompatActivity implements MainView { .show(); return; } - NativeLibrary.InstallCIAS(selectedFiles); - mPresenter.refreshGameList(); + WorkManager workManager = WorkManager.getInstance(getApplicationContext()); + workManager.enqueueUniqueWork("installCiaWork", ExistingWorkPolicy.APPEND_OR_REPLACE, + new OneTimeWorkRequest.Builder(CiaInstallWorker.class) + .setInputData( + new Data.Builder().putStringArray("CIA_FILES", selectedFiles) + .build() + ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + ); }); @Override @@ -233,7 +249,7 @@ public final class MainActivity extends AppCompatActivity implements MainView { mOpenGameListLauncher.launch(null); break; case MainPresenter.REQUEST_INSTALL_CIA: - mOpenFileLauncher.launch(true); + mInstallCiaFileLauncher.launch(true); break; } } else { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java new file mode 100644 index 000000000..6381a41d1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java @@ -0,0 +1,167 @@ +package org.citra.citra_emu.utils; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.work.ForegroundInfo; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.ui.main.MainActivity; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class CiaInstallWorker extends Worker { + private final Context mContext = getApplicationContext(); + + private final NotificationManager mNotificationManager = + mContext.getSystemService(NotificationManager.class); + + static final String GROUP_KEY_CIA_INSTALL_STATUS = "org.citra.citra_emu.CIA_INSTALL_STATUS"; + + private final NotificationCompat.Builder mInstallProgressBuilder = new NotificationCompat.Builder( + mContext, mContext.getString(R.string.cia_install_notification_channel_id)) + .setContentTitle(mContext.getString(R.string.install_cia_title)) + .setContentIntent(PendingIntent.getBroadcast(mContext, 0, + new Intent("CitraDoNothing"), PendingIntent.FLAG_IMMUTABLE)) + .setSmallIcon(R.drawable.ic_stat_notification_logo); + + private final NotificationCompat.Builder mInstallStatusBuilder = new NotificationCompat.Builder( + mContext, mContext.getString(R.string.cia_install_notification_channel_id)) + .setContentTitle(mContext.getString(R.string.install_cia_title)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setGroup(GROUP_KEY_CIA_INSTALL_STATUS); + + private final Notification mSummaryNotification = + new NotificationCompat.Builder(mContext, mContext.getString(R.string.cia_install_notification_channel_id)) + .setContentTitle(mContext.getString(R.string.install_cia_title)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setGroup(GROUP_KEY_CIA_INSTALL_STATUS) + .setGroupSummary(true) + .build(); + + private static long mLastNotifiedTime = 0; + + private static int mStatusNotificationId = 0xC1A0001; + + public CiaInstallWorker( + @NonNull Context context, + @NonNull WorkerParameters params) { + super(context, params); + } + + enum InstallStatus { + Success, + ErrorFailedToOpenFile, + ErrorFileNotFound, + ErrorAborted, + ErrorInvalid, + ErrorEncrypted, + } + + private void notifyInstallStatus(String filename, InstallStatus status) { + switch(status){ + case Success: + mInstallStatusBuilder.setContentTitle( + mContext.getString(R.string.cia_install_notification_success_title)); + mInstallStatusBuilder.setContentText( + mContext.getString(R.string.cia_install_success, filename)); + break; + case ErrorAborted: + mInstallStatusBuilder.setContentTitle( + mContext.getString(R.string.cia_install_notification_error_title)); + mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mContext.getString( + R.string.cia_install_error_aborted, filename))); + break; + case ErrorInvalid: + mInstallStatusBuilder.setContentTitle( + mContext.getString(R.string.cia_install_notification_error_title)); + mInstallStatusBuilder.setContentText( + mContext.getString(R.string.cia_install_error_invalid, filename)); + break; + case ErrorEncrypted: + mInstallStatusBuilder.setContentTitle( + mContext.getString(R.string.cia_install_notification_error_title)); + mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mContext.getString( + R.string.cia_install_error_encrypted, filename))); + break; + case ErrorFailedToOpenFile: + // TODO: + case ErrorFileNotFound: + // shouldn't happen + default: + mInstallStatusBuilder.setContentTitle( + mContext.getString(R.string.cia_install_notification_error_title)); + mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mContext.getString(R.string.cia_install_error_unknown, filename))); + break; + } + // Even if newer versions of Android don't show the group summary text that you design, + // you always need to manually set a summary to enable grouped notifications. + mNotificationManager.notify(0xC1A0000, mSummaryNotification); + mNotificationManager.notify(mStatusNotificationId++, mInstallStatusBuilder.build()); + } + @NonNull + @Override + public Result doWork() { + String[] selectedFiles = getInputData().getStringArray("CIA_FILES"); + assert selectedFiles != null; + final CharSequence toastText = mContext.getResources().getQuantityString(R.plurals.cia_install_toast, + selectedFiles.length, selectedFiles.length); + + getApplicationContext().getMainExecutor().execute(() -> Toast.makeText(mContext, toastText, + Toast.LENGTH_LONG).show()); + + // Issue the initial notification with zero progress + mInstallProgressBuilder.setOngoing(true); + setProgressCallback(100, 0); + + int i = 0; + for (String file : selectedFiles) { + String filename = FileUtil.getFilename(mContext, file); + mInstallProgressBuilder.setContentText(mContext.getString( + R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length)); + InstallStatus res = InstallCIA(file); + notifyInstallStatus(filename, res); + } + mNotificationManager.cancel(0xC1A); + + return Result.success(); + } + public void setProgressCallback(int max, int progress) { + long currentTime = System.currentTimeMillis(); + // Android applies a rate limit when updating a notification. + // If you post updates to a single notification too frequently, + // such as many in less than one second, the system might drop updates. + // TODO: consider moving to C++ side + if (currentTime - mLastNotifiedTime < 500 /* ms */){ + return; + } + mLastNotifiedTime = currentTime; + mInstallProgressBuilder.setProgress(max, progress, false); + mNotificationManager.notify(0xC1A, mInstallProgressBuilder.build()); + } + + @NonNull + @Override + public ForegroundInfo getForegroundInfo() { + return new ForegroundInfo(0xC1A, mInstallProgressBuilder.build()); + } + + private native InstallStatus InstallCIA(String path); +} diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index 2ac6e5da9..c2aa048a8 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -8,6 +8,7 @@ #include "common/logging/filter.h" #include "common/logging/log.h" #include "common/settings.h" +#include "core/hle/service/am/am.h" #include "jni/applets/mii_selector.h" #include "jni/applets/swkbd.h" #include "jni/camera/still_image_camera.h" @@ -45,6 +46,10 @@ static jclass s_disk_cache_progress_class; static jmethodID s_disk_cache_load_progress; static std::unordered_map s_java_load_callback_stages; +static jclass s_cia_install_helper_class; +static jmethodID s_cia_install_helper_set_progress; +static std::unordered_map s_java_cia_install_status; + namespace IDCache { JNIEnv* GetEnvForThread() { @@ -149,6 +154,19 @@ jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage) { return it->second; } +jclass GetCiaInstallHelperClass() { + return s_cia_install_helper_class; +} + +jmethodID GetCiaInstallHelperSetProgress() { + return s_cia_install_helper_set_progress; +} +jobject GetJavaCiaInstallStatus(Service::AM::InstallStatus status) { + const auto it = s_java_cia_install_status.find(status); + ASSERT_MSG(it != s_java_cia_install_status.end(), "Invalid InstallStatus: {}", status); + + return it->second; +} } // namespace IDCache #ifdef __cplusplus @@ -217,15 +235,16 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { env->DeleteLocalRef(game_info_class); // Initialize Disk Shader Cache Progress Dialog - s_disk_cache_progress_class = reinterpret_cast(env->NewGlobalRef( - env->FindClass("org/citra/citra_emu/utils/DiskShaderCacheProgress"))); - jclass load_callback_stage_class = env->FindClass( - "org/citra/citra_emu/utils/DiskShaderCacheProgress$LoadCallbackStage"); + s_disk_cache_progress_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/utils/DiskShaderCacheProgress"))); + jclass load_callback_stage_class = + env->FindClass("org/citra/citra_emu/utils/DiskShaderCacheProgress$LoadCallbackStage"); s_disk_cache_load_progress = env->GetStaticMethodID( - s_disk_cache_progress_class, "loadProgress", - "(Lorg/citra/citra_emu/utils/DiskShaderCacheProgress$LoadCallbackStage;II)V"); + s_disk_cache_progress_class, "loadProgress", + "(Lorg/citra/citra_emu/utils/DiskShaderCacheProgress$LoadCallbackStage;II)V"); // Initialize LoadCallbackStage map - const auto to_java_load_callback_stage = [env, load_callback_stage_class](const std::string& stage) { + const auto to_java_load_callback_stage = [env, + load_callback_stage_class](const std::string& stage) { return env->NewGlobalRef(env->GetStaticObjectField( load_callback_stage_class, env->GetStaticFieldID(load_callback_stage_class, stage.c_str(), @@ -241,6 +260,36 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { s_java_load_callback_stages.emplace(VideoCore::LoadCallbackStage::Complete, to_java_load_callback_stage("Complete")); env->DeleteLocalRef(load_callback_stage_class); + + // CIA Install + s_cia_install_helper_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/utils/CiaInstallWorker"))); + s_cia_install_helper_set_progress = + env->GetMethodID(s_cia_install_helper_class, "setProgressCallback", "(II)V"); + // Initialize CIA InstallStatus map + jclass cia_install_status_class = + env->FindClass("org/citra/citra_emu/utils/CiaInstallWorker$InstallStatus"); + const auto to_java_cia_install_status = [env, + cia_install_status_class](const std::string& stage) { + return env->NewGlobalRef(env->GetStaticObjectField( + cia_install_status_class, env->GetStaticFieldID(cia_install_status_class, stage.c_str(), + "Lorg/citra/citra_emu/utils/" + "CiaInstallWorker$InstallStatus;"))); + }; + s_java_cia_install_status.emplace(Service::AM::InstallStatus::Success, + to_java_cia_install_status("Success")); + s_java_cia_install_status.emplace(Service::AM::InstallStatus::ErrorFailedToOpenFile, + to_java_cia_install_status("ErrorFailedToOpenFile")); + s_java_cia_install_status.emplace(Service::AM::InstallStatus::ErrorFileNotFound, + to_java_cia_install_status("ErrorFileNotFound")); + s_java_cia_install_status.emplace(Service::AM::InstallStatus::ErrorAborted, + to_java_cia_install_status("ErrorAborted")); + s_java_cia_install_status.emplace(Service::AM::InstallStatus::ErrorInvalid, + to_java_cia_install_status("ErrorInvalid")); + s_java_cia_install_status.emplace(Service::AM::InstallStatus::ErrorEncrypted, + to_java_cia_install_status("ErrorEncrypted")); + env->DeleteLocalRef(cia_install_status_class); + MiiSelector::InitJNI(env); SoftwareKeyboard::InitJNI(env); Camera::StillImage::InitJNI(env); @@ -260,11 +309,16 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { env->DeleteGlobalRef(s_disk_cache_progress_class); env->DeleteGlobalRef(s_native_library_class); env->DeleteGlobalRef(s_cheat_class); + env->DeleteGlobalRef(s_cia_install_helper_class); for (auto& [key, object] : s_java_load_callback_stages) { env->DeleteGlobalRef(object); } + for (auto& [key, object] : s_java_cia_install_status) { + env->DeleteGlobalRef(object); + } + MiiSelector::CleanupJNI(env); SoftwareKeyboard::CleanupJNI(env); Camera::StillImage::CleanupJNI(env); diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h index 62a9e066a..3e6d3eb93 100644 --- a/src/android/app/src/main/jni/id_cache.h +++ b/src/android/app/src/main/jni/id_cache.h @@ -9,6 +9,10 @@ #include #include "video_core/rasterizer_interface.h" +namespace Service::AM { +enum class InstallStatus : u32; +} // namespace Service::AM + namespace IDCache { JNIEnv* GetEnvForThread(); @@ -39,6 +43,10 @@ jclass GetDiskCacheProgressClass(); jmethodID GetDiskCacheLoadProgress(); jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage); +jclass GetCiaInstallHelperClass(); +jmethodID GetCiaInstallHelperSetProgress(); +jobject GetJavaCiaInstallStatus(Service::AM::InstallStatus status); + } // namespace IDCache template diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 0c1620ec6..91728029a 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -625,29 +625,16 @@ void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass cl nfc->RemoveAmiibo(); } -void Java_org_citra_citra_1emu_NativeLibrary_InstallCIAS(JNIEnv* env, [[maybe_unused]] jclass clazz, - jobjectArray path) { - const jsize count{env->GetArrayLength(path)}; - std::vector paths; - paths.reserve(count); - for (jsize idx{0}; idx < count; ++idx) { - paths.emplace_back( - GetJString(env, static_cast(env->GetObjectArrayElement(path, idx)))); - } - std::atomic idx{count}; - std::vector threads; - std::generate_n(std::back_inserter(threads), - std::min(std::thread::hardware_concurrency(), count), [&] { - return std::thread{[&idx, &paths, env] { - jsize work_idx; - while ((work_idx = --idx) >= 0) { - LOG_INFO(Frontend, "Installing CIA {}", work_idx); - Service::AM::InstallCIA(paths[work_idx]); - } - }}; - }); - for (auto& thread : threads) - thread.join(); +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_InstallCIA( + JNIEnv* env, jobject jobj, jstring jpath) { + std::string path = GetJString(env, jpath); + Service::AM::InstallStatus res = + Service::AM::InstallCIA(path, [env, jobj](size_t total_bytes_read, size_t file_size) { + env->CallVoidMethod(jobj, IDCache::GetCiaInstallHelperSetProgress(), + static_cast(file_size), static_cast(total_bytes_read)); + }); + + return IDCache::GetJavaCiaInstallStatus(res); } jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo( diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index a2156a4e4..30f012653 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -146,10 +146,6 @@ JNIEXPORT jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* en JNIEXPORT void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz); -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_InstallCIAS(JNIEnv* env, - jclass clazz, - jobjectArray path); - JNIEXPORT jobjectArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(JNIEnv* env, jclass clazz); @@ -162,6 +158,10 @@ JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass clazz); +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_NativeLibrary_InstallCIA(JNIEnv* env, + jclass clazz, + jstring file); + #ifdef __cplusplus } #endif diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 03c403ece..b8c677664 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -262,4 +262,22 @@ Name can\'t be empty Code can\'t be empty Error on line %1$d + + + + Installing %d file. See notification for more details. + Installing %d files. See notification for more details. + + Citra CIA Install + citra-cia + Citra notifications during CIA Install + Installing CIA + Installing %s (%d/%d) + Successfully installed CIA + Failed to install CIA + \"%s\" has been installed successfully + The installation of \"%s\" was aborted.\n Please see the log for more details + \"%s\" is not a valid CIA + \"%s\" must be decrypted before being used with Citra.\n A real 3DS is required + An unknown error occurred while installing \"%s\".\n Please see the log for more details