diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts
index 36f215433..3a34cbf0f 100644
--- a/src/android/app/build.gradle.kts
+++ b/src/android/app/build.gradle.kts
@@ -2,15 +2,18 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
+import android.databinding.tool.ext.capitalizeUS
+import de.undercouch.gradle.tasks.download.Download
+
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("de.undercouch.download") version "5.5.0"
+ id("kotlin-parcelize")
+ kotlin("plugin.serialization") version "1.8.21"
+ id("androidx.navigation.safeargs.kotlin")
}
-import android.databinding.tool.ext.capitalizeUS
-import de.undercouch.gradle.tasks.download.Download
-
/**
* Use the number of seconds/10 since Jan 1 2016 as the versionCode.
* This lets us upload a new build at most every 10 seconds for the
@@ -25,7 +28,7 @@ val downloadedJniLibsPath = "${buildDir}/downloadedJniLibs"
android {
namespace = "org.citra.citra_emu"
- compileSdkVersion = "android-33"
+ compileSdkVersion = "android-34"
ndkVersion = "25.2.9519653"
compileOptions {
@@ -37,6 +40,11 @@ android {
jvmTarget = "17"
}
+ packaging {
+ // This is necessary for libadrenotools custom driver loading
+ jniLibs.useLegacyPackaging = true
+ }
+
buildFeatures {
viewBinding = true
}
@@ -51,7 +59,7 @@ android {
// TODO If this is ever modified, change application_id in strings.xml
applicationId = "org.citra.citra_emu"
minSdk = 28
- targetSdk = 33
+ targetSdk = 34
versionCode = autoVersion
versionName = getGitVersion()
@@ -69,6 +77,9 @@ android {
)
}
}
+
+ buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
+ buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
}
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
@@ -92,6 +103,12 @@ android {
} else {
signingConfigs.getByName("debug")
}
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android.txt"),
+ "proguard-rules.pro"
+ )
}
// builds a release build that doesn't need signing
@@ -101,9 +118,15 @@ android {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
isDebuggable = true
isJniDebuggable = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android.txt"),
+ "proguard-rules.pro"
+ )
+ isDefault = true
}
// Signed by debug key disallowing distribution on Play Store.
@@ -145,8 +168,9 @@ android {
}
dependencies {
- implementation("androidx.activity:activity-ktx:1.7.2")
- implementation("androidx.fragment:fragment-ktx:1.6.0")
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
+ implementation("androidx.activity:activity-ktx:1.8.0")
+ implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
@@ -158,15 +182,14 @@ dependencies {
// For loading huge screenshots from the disk.
implementation("com.squareup.picasso:picasso:2.71828")
- // Allows FRP-style asynchronous operations in Android.
- implementation("io.reactivex:rxandroid:1.2.1")
-
implementation("org.ini4j:ini4j:0.5.4")
- implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
-
- // Please don't upgrade the billing library as the newer version is not GPL-compatible
- implementation("com.android.billingclient:billing:2.0.3")
+ implementation("androidx.navigation:navigation-fragment-ktx:2.7.5")
+ implementation("androidx.navigation:navigation-ui-ktx:2.7.5")
+ implementation("info.debatty:java-string-similarity:2.0.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
+ implementation("androidx.preference:preference-ktx:1.2.1")
+ implementation("io.coil-kt:coil:2.2.2")
}
// Download Vulkan Validation Layers from the KhronosGroup GitHub.
@@ -216,6 +239,34 @@ fun getGitVersion(): String {
return versionName
}
+fun getGitHash(): String =
+ runGitCommand(ProcessBuilder("git", "rev-parse", "--short", "HEAD")) ?: "dummy-hash"
+
+fun getBranch(): String =
+ runGitCommand(ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")) ?: "dummy-branch"
+
+fun runGitCommand(command: ProcessBuilder) : String? {
+ try {
+ command.directory(project.rootDir)
+ val process = command.start()
+ val inputStream = process.inputStream
+ val errorStream = process.errorStream
+ process.waitFor()
+
+ return if (process.exitValue() == 0) {
+ inputStream.bufferedReader()
+ .use { it.readText().trim() } // return the value of gitHash
+ } else {
+ val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
+ logger.error("Error running git command: $errorMessage")
+ return null
+ }
+ } catch (e: Exception) {
+ logger.error("$e: Cannot find git")
+ return null
+ }
+}
+
android.applicationVariants.configureEach {
val variant = this
val capitalizedName = variant.name.capitalizeUS()
diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro
index f1b424510..b0a4f4ca6 100644
--- a/src/android/app/proguard-rules.pro
+++ b/src/android/app/proguard-rules.pro
@@ -1,21 +1,25 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
+# Copyright 2023 Citra Emulator Project
+# Licensed under GPLv2 or any later version
+# Refer to the license.txt file included.
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
+# To get usable stack traces
+-dontobfuscate
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
+# Prevents crashing when using Wini
+-keep class org.ini4j.spi.IniParser
+-keep class org.ini4j.spi.IniBuilder
+-keep class org.ini4j.spi.IniFormatter
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# Suppress warnings for R8
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
+-dontwarn java.beans.Introspector
+-dontwarn java.beans.VetoableChangeListener
+-dontwarn java.beans.VetoableChangeSupport
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 1e8494682..416321ce8 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,7 @@
+
+ android:exported="true">
@@ -68,21 +68,15 @@
android:theme="@style/Theme.Citra.Main"
android:launchMode="singleTop"/>
-
+
+
+
-
-
-
-
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
deleted file mode 100644
index b57226070..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright 2019 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-package org.citra.citra_emu;
-
-import android.app.Application;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.content.Context;
-import android.os.Build;
-
-import org.citra.citra_emu.model.GameDatabase;
-import org.citra.citra_emu.utils.DirectoryInitialization;
-import org.citra.citra_emu.utils.DocumentsTree;
-import org.citra.citra_emu.utils.PermissionsHandler;
-
-public class CitraApplication extends Application {
- public static GameDatabase databaseHelper;
- public static DocumentsTree documentsTree;
- private static CitraApplication 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) {
- 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);
- channel.setDescription(description);
- channel.setSound(null, null);
- channel.setVibrationPattern(null);
-
- 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);
- }
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- application = this;
- documentsTree = new DocumentsTree();
-
- if (PermissionsHandler.hasWriteAccess(getApplicationContext())) {
- DirectoryInitialization.start(getApplicationContext());
- }
-
- NativeLibrary.LogDeviceInfo();
- createNotificationChannel();
-
- databaseHelper = new GameDatabase(this);
- }
-
- public static Context getAppContext() {
- return application.getApplicationContext();
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt
new file mode 100644
index 000000000..c414d4246
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt
@@ -0,0 +1,67 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import org.citra.citra_emu.utils.DirectoryInitialization
+import org.citra.citra_emu.utils.DocumentsTree
+import org.citra.citra_emu.utils.GpuDriverHelper
+import org.citra.citra_emu.utils.PermissionsHandler
+
+class CitraApplication : Application() {
+ private fun createNotificationChannel() {
+ with(getSystemService(NotificationManager::class.java)) {
+ // General notification
+ val name: CharSequence = getString(R.string.app_notification_channel_name)
+ val description = getString(R.string.app_notification_channel_description)
+ val generalChannel = NotificationChannel(
+ getString(R.string.app_notification_channel_id),
+ name,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ generalChannel.description = description
+ generalChannel.setSound(null, null)
+ generalChannel.vibrationPattern = null
+ createNotificationChannel(generalChannel)
+
+ // CIA Install notifications
+ val ciaChannel = NotificationChannel(
+ getString(R.string.cia_install_notification_channel_id),
+ getString(R.string.cia_install_notification_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+ ciaChannel.description =
+ getString(R.string.cia_install_notification_channel_description)
+ ciaChannel.setSound(null, null)
+ ciaChannel.vibrationPattern = null
+ createNotificationChannel(ciaChannel)
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ application = this
+ documentsTree = DocumentsTree()
+ if (PermissionsHandler.hasWriteAccess(applicationContext)) {
+ DirectoryInitialization.start()
+ }
+
+ NativeLibrary.logDeviceInfo()
+ createNotificationChannel()
+ }
+
+ companion object {
+ private var application: CitraApplication? = null
+
+ val appContext: Context get() = application!!.applicationContext
+
+ @SuppressLint("StaticFieldLeak")
+ lateinit var documentsTree: DocumentsTree
+ }
+}
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
deleted file mode 100644
index 2e431eb92..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
+++ /dev/null
@@ -1,720 +0,0 @@
-/*
- * Copyright 2013 Dolphin Emulator Project
- * Licensed under GPLv2+
- * Refer to the license.txt file included.
- */
-
-package org.citra.citra_emu;
-
-import android.app.Activity;
-import android.app.Dialog;
-import android.content.pm.PackageManager;
-import android.content.res.Configuration;
-import android.os.Bundle;
-import android.text.Html;
-import android.text.method.LinkMovementMethod;
-import android.view.Surface;
-import android.view.ViewGroup;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-import androidx.core.content.ContextCompat;
-import androidx.fragment.app.DialogFragment;
-
-import org.citra.citra_emu.activities.EmulationActivity;
-import org.citra.citra_emu.applets.SoftwareKeyboard;
-import org.citra.citra_emu.utils.EmulationMenuSettings;
-import org.citra.citra_emu.utils.FileUtil;
-import org.citra.citra_emu.utils.Log;
-import org.citra.citra_emu.utils.PermissionsHandler;
-
-import java.lang.ref.WeakReference;
-import java.util.Date;
-import java.util.Objects;
-
-import static android.Manifest.permission.CAMERA;
-import static android.Manifest.permission.RECORD_AUDIO;
-
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
-/**
- * Class which contains methods that interact
- * with the native side of the Citra code.
- */
-public final class NativeLibrary {
- /**
- * Default touchscreen device
- */
- public static final String TouchScreenDevice = "Touchscreen";
- public static WeakReference sEmulationActivity = new WeakReference<>(null);
-
- private static boolean alertResult = false;
- private static String alertPromptResult = "";
- private static int alertPromptButton = 0;
- private static final Object alertPromptLock = new Object();
- private static boolean alertPromptInProgress = false;
- private static String alertPromptCaption = "";
- private static int alertPromptButtonConfig = 0;
- private static EditText alertPromptEditText = null;
-
- static {
- try {
- System.loadLibrary("citra-android");
- } catch (UnsatisfiedLinkError ex) {
- Log.error("[NativeLibrary] " + ex.toString());
- }
- }
-
- private NativeLibrary() {
- // Disallows instantiation.
- }
-
- /**
- * Handles button press events for a gamepad.
- *
- * @param Device The input descriptor of the gamepad.
- * @param Button Key code identifying which button was pressed.
- * @param Action Mask identifying which action is happening (button pressed down, or button released).
- * @return If we handled the button press.
- */
- public static native boolean onGamePadEvent(String Device, int Button, int Action);
-
- /**
- * Handles gamepad movement events.
- *
- * @param Device The device ID of the gamepad.
- * @param Axis The axis ID
- * @param x_axis The value of the x-axis represented by the given ID.
- * @param y_axis The value of the y-axis represented by the given ID
- */
- public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis);
-
- /**
- * Handles gamepad movement events.
- *
- * @param Device The device ID of the gamepad.
- * @param Axis_id The axis ID
- * @param axis_val The value of the axis represented by the given ID.
- */
- public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val);
-
- /**
- * Handles touch events.
- *
- * @param x_axis The value of the x-axis.
- * @param y_axis The value of the y-axis
- * @param pressed To identify if the touch held down or released.
- * @return true if the pointer is within the touchscreen
- */
- public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed);
-
- /**
- * Handles touch movement.
- *
- * @param x_axis The value of the instantaneous x-axis.
- * @param y_axis The value of the instantaneous y-axis.
- */
- public static native void onTouchMoved(float x_axis, float y_axis);
-
- public static native void ReloadSettings();
-
- public static native String GetUserSetting(String gameID, String Section, String Key);
-
- public static native void SetUserSetting(String gameID, String Section, String Key, String Value);
-
- public static native void InitGameIni(String gameID);
-
- public static native long GetTitleId(String filename);
-
- public static native String GetGitRevision();
-
- /**
- * Sets the current working user directory
- * If not set, it auto-detects a location
- */
- public static native void SetUserDirectory(String directory);
-
- public static native String[] GetInstalledGamePaths();
-
- // Create the config.ini file.
- public static native void CreateConfigFile();
-
- public static native void CreateLogFile();
-
- public static native void LogUserDirectory(String directory);
-
- public static native int DefaultCPUCore();
-
- /**
- * Begins emulation.
- */
- public static native void Run(String path);
-
- public static native String[] GetTextureFilterNames();
-
- /**
- * Begins emulation from the specified savestate.
- */
- public static native void Run(String path, String savestatePath, boolean deleteSavestate);
-
- // Surface Handling
- public static native void SurfaceChanged(Surface surf);
-
- public static native void SurfaceDestroyed();
-
- public static native void DoFrame();
-
- /**
- * Unpauses emulation from a paused state.
- */
- public static native void UnPauseEmulation();
-
- /**
- * Pauses emulation.
- */
- public static native void PauseEmulation();
-
- /**
- * Stops emulation.
- */
- public static native void StopEmulation();
-
- /**
- * Returns true if emulation is running (or is paused).
- */
- public static native boolean IsRunning();
-
- /**
- * Returns the title ID of the currently running title, or 0 on failure.
- */
- public static native long GetRunningTitleId();
-
- /**
- * Returns the performance stats for the current game
- **/
- public static native double[] GetPerfStats();
-
- /**
- * Notifies the core emulation that the orientation has changed.
- */
- public static native void NotifyOrientationChange(int layout_option, int rotation);
-
- /**
- * Swaps the top and bottom screens.
- */
- public static native void SwapScreens(boolean swap_screens, int rotation);
-
- public enum CoreError {
- ErrorSystemFiles,
- ErrorSavestate,
- ErrorUnknown,
- }
-
- private static boolean coreErrorAlertResult = false;
- private static final Object coreErrorAlertLock = new Object();
-
- public static class CoreErrorDialogFragment extends DialogFragment {
- static CoreErrorDialogFragment newInstance(String title, String message) {
- CoreErrorDialogFragment frag = new CoreErrorDialogFragment();
- Bundle args = new Bundle();
- args.putString("title", title);
- args.putString("message", message);
- frag.setArguments(args);
- return frag;
- }
-
- @NonNull
- @Override
- public Dialog onCreateDialog(Bundle savedInstanceState) {
- final Activity emulationActivity = Objects.requireNonNull(getActivity());
-
- final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title"));
- final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message"));
-
- return new MaterialAlertDialogBuilder(emulationActivity)
- .setTitle(title)
- .setMessage(message)
- .setPositiveButton(R.string.continue_button, (dialog, which) -> {
- coreErrorAlertResult = true;
- synchronized (coreErrorAlertLock) {
- coreErrorAlertLock.notify();
- }
- })
- .setNegativeButton(R.string.abort_button, (dialog, which) -> {
- coreErrorAlertResult = false;
- synchronized (coreErrorAlertLock) {
- coreErrorAlertLock.notify();
- }
- }).setOnDismissListener(dialog -> {
- coreErrorAlertResult = true;
- synchronized (coreErrorAlertLock) {
- coreErrorAlertLock.notify();
- }
- }).create();
- }
- }
-
- private static void OnCoreErrorImpl(String title, String message) {
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- if (emulationActivity == null) {
- Log.error("[NativeLibrary] EmulationActivity not present");
- return;
- }
-
- CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message);
- fragment.show(emulationActivity.getSupportFragmentManager(), "coreError");
- }
-
- /**
- * Handles a core error.
- * @return true: continue; false: abort
- */
- public static boolean OnCoreError(CoreError error, String details) {
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- if (emulationActivity == null) {
- Log.error("[NativeLibrary] EmulationActivity not present");
- return false;
- }
-
- String title, message;
- switch (error) {
- case ErrorSystemFiles: {
- title = emulationActivity.getString(R.string.system_archive_not_found);
- message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details);
- break;
- }
- case ErrorSavestate: {
- title = emulationActivity.getString(R.string.save_load_error);
- message = details;
- break;
- }
- case ErrorUnknown: {
- title = emulationActivity.getString(R.string.fatal_error);
- message = emulationActivity.getString(R.string.fatal_error_message);
- break;
- }
- default: {
- return true;
- }
- }
-
- // Show the AlertDialog on the main thread.
- emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message));
-
- // Wait for the lock to notify that it is complete.
- synchronized (coreErrorAlertLock) {
- try {
- coreErrorAlertLock.wait();
- } catch (Exception ignored) {
- }
- }
-
- return coreErrorAlertResult;
- }
-
- public static boolean isPortraitMode() {
- return CitraApplication.getAppContext().getResources().getConfiguration().orientation ==
- Configuration.ORIENTATION_PORTRAIT;
- }
-
- public static int landscapeScreenLayout() {
- return EmulationMenuSettings.getLandscapeScreenLayout();
- }
-
- public static boolean displayAlertMsg(final String caption, final String text,
- final boolean yesNo) {
- Log.error("[NativeLibrary] Alert: " + text);
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- boolean result = false;
- if (emulationActivity == null) {
- Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert.");
- } else {
- // Create object used for waiting.
- final Object lock = new Object();
- MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity)
- .setTitle(caption)
- .setMessage(text);
-
- // If not yes/no dialog just have one button that dismisses modal,
- // otherwise have a yes and no button that sets alertResult accordingly.
- if (!yesNo) {
- builder
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok, (dialog, whichButton) ->
- {
- dialog.dismiss();
- synchronized (lock) {
- lock.notify();
- }
- });
- } else {
- alertResult = false;
-
- builder
- .setPositiveButton(android.R.string.yes, (dialog, whichButton) ->
- {
- alertResult = true;
- dialog.dismiss();
- synchronized (lock) {
- lock.notify();
- }
- })
- .setNegativeButton(android.R.string.no, (dialog, whichButton) ->
- {
- alertResult = false;
- dialog.dismiss();
- synchronized (lock) {
- lock.notify();
- }
- });
- }
-
- // Show the AlertDialog on the main thread.
- emulationActivity.runOnUiThread(builder::show);
-
- // Wait for the lock to notify that it is complete.
- synchronized (lock) {
- try {
- lock.wait();
- } catch (Exception e) {
- }
- }
-
- if (yesNo)
- result = alertResult;
- }
- return result;
- }
-
- public static void retryDisplayAlertPrompt() {
- if (!alertPromptInProgress) {
- return;
- }
- displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show();
- }
-
- public static String displayAlertPrompt(String caption, String text, int buttonConfig) {
- alertPromptCaption = caption;
- alertPromptButtonConfig = buttonConfig;
- alertPromptInProgress = true;
-
- // Show the AlertDialog on the main thread
- sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show());
-
- // Wait for the lock to notify that it is complete
- synchronized (alertPromptLock) {
- try {
- alertPromptLock.wait();
- } catch (Exception e) {
- }
- }
- alertPromptInProgress = false;
-
- return alertPromptResult;
- }
-
- public static MaterialAlertDialogBuilder displayAlertPromptImpl(String caption, String text, int buttonConfig) {
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- alertPromptResult = "";
- alertPromptButton = 0;
-
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin);
-
- // Set up the input
- alertPromptEditText = new EditText(CitraApplication.getAppContext());
- alertPromptEditText.setText(text);
- alertPromptEditText.setSingleLine();
- alertPromptEditText.setLayoutParams(params);
-
- FrameLayout container = new FrameLayout(emulationActivity);
- container.addView(alertPromptEditText);
-
- MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity)
- .setTitle(caption)
- .setView(container)
- .setPositiveButton(android.R.string.ok, (dialogInterface, i) ->
- {
- alertPromptButton = buttonConfig;
- alertPromptResult = alertPromptEditText.getText().toString();
- synchronized (alertPromptLock) {
- alertPromptLock.notifyAll();
- }
- })
- .setOnDismissListener(dialogInterface ->
- {
- alertPromptResult = "";
- synchronized (alertPromptLock) {
- alertPromptLock.notifyAll();
- }
- });
-
- if (buttonConfig > 0) {
- builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) ->
- {
- alertPromptResult = "";
- synchronized (alertPromptLock) {
- alertPromptLock.notifyAll();
- }
- });
- }
-
- return builder;
- }
-
- public static int alertPromptButton() {
- return alertPromptButton;
- }
-
- public static void exitEmulationActivity(int resultCode) {
- final int Success = 0;
- final int ErrorNotInitialized = 1;
- final int ErrorGetLoader = 2;
- final int ErrorSystemMode = 3;
- final int ErrorLoader = 4;
- final int ErrorLoader_ErrorEncrypted = 5;
- final int ErrorLoader_ErrorInvalidFormat = 6;
- final int ErrorSystemFiles = 7;
- final int ShutdownRequested = 11;
- final int ErrorUnknown = 12;
-
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- if (emulationActivity == null) {
- Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.");
- return;
- }
-
- int captionId = R.string.loader_error_invalid_format;
- if (resultCode == ErrorLoader_ErrorEncrypted) {
- captionId = R.string.loader_error_encrypted;
- }
-
- MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity)
- .setTitle(captionId)
- .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY))
- .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish())
- .setOnDismissListener(dialogInterface -> emulationActivity.finish());
- emulationActivity.runOnUiThread(() -> {
- AlertDialog alert = builder.create();
- alert.show();
- ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance());
- });
- }
-
- public static void setEmulationActivity(EmulationActivity emulationActivity) {
- Log.verbose("[NativeLibrary] Registering EmulationActivity.");
- sEmulationActivity = new WeakReference<>(emulationActivity);
- }
-
- public static void clearEmulationActivity() {
- Log.verbose("[NativeLibrary] Unregistering EmulationActivity.");
-
- sEmulationActivity.clear();
- }
-
- private static final Object cameraPermissionLock = new Object();
- private static boolean cameraPermissionGranted = false;
- public static final int REQUEST_CODE_NATIVE_CAMERA = 800;
-
- public static boolean RequestCameraPermission() {
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- if (emulationActivity == null) {
- Log.error("[NativeLibrary] EmulationActivity not present");
- return false;
- }
- if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) {
- // Permission already granted
- return true;
- }
- emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA);
-
- // Wait until result is returned
- synchronized (cameraPermissionLock) {
- try {
- cameraPermissionLock.wait();
- } catch (InterruptedException ignored) {
- }
- }
- return cameraPermissionGranted;
- }
-
- public static void CameraPermissionResult(boolean granted) {
- cameraPermissionGranted = granted;
- synchronized (cameraPermissionLock) {
- cameraPermissionLock.notify();
- }
- }
-
- private static final Object micPermissionLock = new Object();
- private static boolean micPermissionGranted = false;
- public static final int REQUEST_CODE_NATIVE_MIC = 900;
-
- public static boolean RequestMicPermission() {
- final EmulationActivity emulationActivity = sEmulationActivity.get();
- if (emulationActivity == null) {
- Log.error("[NativeLibrary] EmulationActivity not present");
- return false;
- }
- if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
- // Permission already granted
- return true;
- }
- emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC);
-
- // Wait until result is returned
- synchronized (micPermissionLock) {
- try {
- micPermissionLock.wait();
- } catch (InterruptedException ignored) {
- }
- }
- return micPermissionGranted;
- }
-
- public static void MicPermissionResult(boolean granted) {
- micPermissionGranted = granted;
- synchronized (micPermissionLock) {
- micPermissionLock.notify();
- }
- }
-
- /// Notifies that the activity is now in foreground and camera devices can now be reloaded
- public static native void ReloadCameraDevices();
-
- public static native boolean LoadAmiibo(String path);
-
- public static native void RemoveAmiibo();
-
- public static final int SAVESTATE_SLOT_COUNT = 10;
-
- public static final class SavestateInfo {
- public int slot;
- public Date time;
- }
-
- @Nullable
- public static native SavestateInfo[] GetSavestateInfo();
-
- public static native void SaveState(int slot);
- public static native void LoadState(int slot);
-
- /**
- * Logs the Citra version, Android version and, CPU.
- */
- public static native void LogDeviceInfo();
-
- /**
- * Button type for use in onTouchEvent
- */
- public static final class ButtonType {
- public static final int BUTTON_A = 700;
- public static final int BUTTON_B = 701;
- public static final int BUTTON_X = 702;
- public static final int BUTTON_Y = 703;
- public static final int BUTTON_START = 704;
- public static final int BUTTON_SELECT = 705;
- public static final int BUTTON_HOME = 706;
- public static final int BUTTON_ZL = 707;
- public static final int BUTTON_ZR = 708;
- public static final int DPAD_UP = 709;
- public static final int DPAD_DOWN = 710;
- public static final int DPAD_LEFT = 711;
- public static final int DPAD_RIGHT = 712;
- public static final int STICK_LEFT = 713;
- public static final int STICK_LEFT_UP = 714;
- public static final int STICK_LEFT_DOWN = 715;
- public static final int STICK_LEFT_LEFT = 716;
- public static final int STICK_LEFT_RIGHT = 717;
- public static final int STICK_C = 718;
- public static final int STICK_C_UP = 719;
- public static final int STICK_C_DOWN = 720;
- public static final int STICK_C_LEFT = 771;
- public static final int STICK_C_RIGHT = 772;
- public static final int TRIGGER_L = 773;
- public static final int TRIGGER_R = 774;
- public static final int DPAD = 780;
- public static final int BUTTON_DEBUG = 781;
- public static final int BUTTON_GPIO14 = 782;
- }
-
- /**
- * Button states
- */
- public static final class ButtonState {
- public static final int RELEASED = 0;
- public static final int PRESSED = 1;
- }
- public static boolean createFile(String directory, String filename) {
- if (FileUtil.isNativePath(directory)) {
- return CitraApplication.documentsTree.createFile(directory, filename);
- }
- return FileUtil.createFile(CitraApplication.getAppContext(), directory, filename) != null;
- }
-
- public static boolean createDir(String directory, String directoryName) {
- if (FileUtil.isNativePath(directory)) {
- return CitraApplication.documentsTree.createDir(directory, directoryName);
- }
- return FileUtil.createDir(CitraApplication.getAppContext(), directory, directoryName) != null;
- }
-
- public static int openContentUri(String path, String openMode) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.openContentUri(path, openMode);
- }
- return FileUtil.openContentUri(CitraApplication.getAppContext(), path, openMode);
- }
-
- public static String[] getFilesName(String path) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.getFilesName(path);
- }
- return FileUtil.getFilesName(CitraApplication.getAppContext(), path);
- }
-
- public static long getSize(String path) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.getFileSize(path);
- }
- return FileUtil.getFileSize(CitraApplication.getAppContext(), path);
- }
-
- public static boolean fileExists(String path) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.Exists(path);
- }
- return FileUtil.Exists(CitraApplication.getAppContext(), path);
- }
-
- public static boolean isDirectory(String path) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.isDirectory(path);
- }
- return FileUtil.isDirectory(CitraApplication.getAppContext(), path);
- }
-
- public static boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) {
- if (FileUtil.isNativePath(sourcePath) && FileUtil.isNativePath(destinationParentPath)) {
- return CitraApplication.documentsTree.copyFile(sourcePath, destinationParentPath, destinationFilename);
- }
- return FileUtil.copyFile(CitraApplication.getAppContext(), sourcePath, destinationParentPath, destinationFilename);
- }
-
- public static boolean renameFile(String path, String destinationFilename) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.renameFile(path, destinationFilename);
- }
- return FileUtil.renameFile(CitraApplication.getAppContext(), path, destinationFilename);
- }
-
- public static boolean deleteDocument(String path) {
- if (FileUtil.isNativePath(path)) {
- return CitraApplication.documentsTree.deleteDocument(path);
- }
- return FileUtil.deleteDocument(CitraApplication.getAppContext(), path);
- }
-}
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
new file mode 100644
index 000000000..ebcfa1933
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt
@@ -0,0 +1,728 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu
+
+import android.Manifest.permission
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.pm.PackageManager
+import android.content.res.Configuration
+import android.net.Uri
+import android.os.Bundle
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.Surface
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.Keep
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.citra.citra_emu.activities.EmulationActivity
+import org.citra.citra_emu.utils.EmulationMenuSettings
+import org.citra.citra_emu.utils.FileUtil
+import org.citra.citra_emu.utils.Log
+import java.lang.ref.WeakReference
+import java.util.Date
+
+/**
+ * Class which contains methods that interact
+ * with the native side of the Citra code.
+ */
+object NativeLibrary {
+ /**
+ * Default touchscreen device
+ */
+ const val TouchScreenDevice = "Touchscreen"
+
+ @JvmField
+ var sEmulationActivity = WeakReference(null)
+ private var alertResult = false
+ val alertLock = Object()
+
+ init {
+ try {
+ System.loadLibrary("citra-android")
+ } catch (ex: UnsatisfiedLinkError) {
+ Log.error("[NativeLibrary] $ex")
+ }
+ }
+
+ /**
+ * Handles button press events for a gamepad.
+ *
+ * @param device The input descriptor of the gamepad.
+ * @param button Key code identifying which button was pressed.
+ * @param action Mask identifying which action is happening (button pressed down, or button released).
+ * @return If we handled the button press.
+ */
+ external fun onGamePadEvent(device: String, button: Int, action: Int): Boolean
+
+ /**
+ * Handles gamepad movement events.
+ *
+ * @param device The device ID of the gamepad.
+ * @param axis The axis ID
+ * @param xAxis The value of the x-axis represented by the given ID.
+ * @param yAxis The value of the y-axis represented by the given ID
+ */
+ external fun onGamePadMoveEvent(device: String, axis: Int, xAxis: Float, yAxis: Float): Boolean
+
+ /**
+ * Handles gamepad movement events.
+ *
+ * @param device The device ID of the gamepad.
+ * @param axisId The axis ID
+ * @param axisVal The value of the axis represented by the given ID.
+ */
+ external fun onGamePadAxisEvent(device: String?, axisId: Int, axisVal: Float): Boolean
+
+ /**
+ * Handles touch events.
+ *
+ * @param xAxis The value of the x-axis.
+ * @param yAxis The value of the y-axis
+ * @param pressed To identify if the touch held down or released.
+ * @return true if the pointer is within the touchscreen
+ */
+ external fun onTouchEvent(xAxis: Float, yAxis: Float, pressed: Boolean): Boolean
+
+ /**
+ * Handles touch movement.
+ *
+ * @param xAxis The value of the instantaneous x-axis.
+ * @param yAxis The value of the instantaneous y-axis.
+ */
+ external fun onTouchMoved(xAxis: Float, yAxis: Float)
+
+ external fun reloadSettings()
+
+ external fun getTitleId(filename: String): Long
+
+ external fun getIsSystemTitle(path: String): Boolean
+
+ /**
+ * Sets the current working user directory
+ * If not set, it auto-detects a location
+ */
+ external fun setUserDirectory(directory: String)
+ external fun getInstalledGamePaths(): Array
+
+ // Create the config.ini file.
+ external fun createConfigFile()
+ external fun createLogFile()
+ external fun logUserDirectory(directory: String)
+
+ /**
+ * Begins emulation.
+ */
+ external fun run(path: String)
+
+ // Surface Handling
+ external fun surfaceChanged(surf: Surface)
+ external fun surfaceDestroyed()
+ external fun doFrame()
+
+ /**
+ * Unpauses emulation from a paused state.
+ */
+ external fun unPauseEmulation()
+
+ /**
+ * Pauses emulation.
+ */
+ external fun pauseEmulation()
+
+ /**
+ * Stops emulation.
+ */
+ external fun stopEmulation()
+
+ /**
+ * Returns true if emulation is running (or is paused).
+ */
+ external fun isRunning(): Boolean
+
+ /**
+ * Returns the title ID of the currently running title, or 0 on failure.
+ */
+ external fun getRunningTitleId(): Long
+
+ /**
+ * Returns the performance stats for the current game
+ */
+ external fun getPerfStats(): DoubleArray
+
+ /**
+ * Notifies the core emulation that the orientation has changed.
+ */
+ external fun notifyOrientationChange(layoutOption: Int, rotation: Int)
+
+ /**
+ * Swaps the top and bottom screens.
+ */
+ external fun swapScreens(swapScreens: Boolean, rotation: Int)
+
+ external fun initializeGpuDriver(
+ hookLibDir: String?,
+ customDriverDir: String?,
+ customDriverName: String?,
+ fileRedirectDir: String?
+ )
+
+ external fun areKeysAvailable(): Boolean
+
+ external fun getHomeMenuPath(region: Int): String
+
+ external fun getSystemTitleIds(systemType: Int, region: Int): LongArray
+
+ external fun downloadTitleFromNus(title: Long): InstallStatus
+
+ private var coreErrorAlertResult = false
+ private val coreErrorAlertLock = Object()
+
+ private fun onCoreErrorImpl(title: String, message: String) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present")
+ return
+ }
+ val fragment = CoreErrorDialogFragment.newInstance(title, message)
+ fragment.show(emulationActivity.supportFragmentManager, CoreErrorDialogFragment.TAG)
+ }
+
+ /**
+ * Handles a core error.
+ * @return true: continue; false: abort
+ */
+ @Keep
+ @JvmStatic
+ fun onCoreError(error: CoreError?, details: String): Boolean {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present")
+ return false
+ }
+ val title: String
+ val message: String
+ when (error) {
+ CoreError.ErrorSystemFiles -> {
+ title = emulationActivity.getString(R.string.system_archive_not_found)
+ message = emulationActivity.getString(
+ R.string.system_archive_not_found_message,
+ details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) }
+ )
+ }
+
+ CoreError.ErrorSavestate -> {
+ title = emulationActivity.getString(R.string.save_load_error)
+ message = details
+ }
+
+ CoreError.ErrorUnknown -> {
+ title = emulationActivity.getString(R.string.fatal_error)
+ message = emulationActivity.getString(R.string.fatal_error_message)
+ }
+
+ else -> {
+ return true
+ }
+ }
+
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) })
+
+ // Wait for the lock to notify that it is complete.
+ synchronized(coreErrorAlertLock) {
+ try {
+ coreErrorAlertLock.wait()
+ } catch (ignored: Exception) {
+ }
+ }
+ return coreErrorAlertResult
+ }
+
+ @get:Keep
+ @get:JvmStatic
+ val isPortraitMode: Boolean
+ get() = CitraApplication.appContext.resources.configuration.orientation ==
+ Configuration.ORIENTATION_PORTRAIT
+
+ @Keep
+ @JvmStatic
+ fun landscapeScreenLayout(): Int = EmulationMenuSettings.getLandscapeScreenLayout()
+
+ @Keep
+ @JvmStatic
+ fun displayAlertMsg(title: String, message: String, yesNo: Boolean): Boolean {
+ Log.error("[NativeLibrary] Alert: $message")
+ val emulationActivity = sEmulationActivity.get()
+ var result = false
+ if (emulationActivity == null) {
+ Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert.")
+ } else {
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread {
+ AlertMessageDialogFragment.newInstance(title, message, yesNo).showNow(
+ emulationActivity.supportFragmentManager,
+ AlertMessageDialogFragment.TAG
+ )
+ }
+
+ // Wait for the lock to notify that it is complete.
+ synchronized(alertLock) {
+ try {
+ alertLock.wait()
+ } catch (_: Exception) {
+ }
+ }
+ if (yesNo) result = alertResult
+ }
+ return result
+ }
+
+ class AlertMessageDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ // Create object used for waiting.
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(requireArguments().getString(TITLE))
+ .setMessage(requireArguments().getString(MESSAGE))
+
+ // If not yes/no dialog just have one button that dismisses modal,
+ // otherwise have a yes and no button that sets alertResult accordingly.
+ if (!requireArguments().getBoolean(YES_NO)) {
+ builder
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+ synchronized(alertLock) { alertLock.notify() }
+ }
+ } else {
+ alertResult = false
+ builder
+ .setPositiveButton(android.R.string.yes) { _: DialogInterface, _: Int ->
+ alertResult = true
+ synchronized(alertLock) { alertLock.notify() }
+ }
+ .setNegativeButton(android.R.string.no) { _: DialogInterface, _: Int ->
+ alertResult = false
+ synchronized(alertLock) { alertLock.notify() }
+ }
+ }
+
+ return builder.show()
+ }
+
+ companion object {
+ const val TAG = "AlertMessageDialogFragment"
+
+ const val TITLE = "title"
+ const val MESSAGE = "message"
+ const val YES_NO = "yesNo"
+
+ fun newInstance(
+ title: String,
+ message: String,
+ yesNo: Boolean
+ ): AlertMessageDialogFragment {
+ val args = Bundle()
+ args.putString(TITLE, title)
+ args.putString(MESSAGE, message)
+ args.putBoolean(YES_NO, yesNo)
+ val fragment = AlertMessageDialogFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ fun exitEmulationActivity(resultCode: Int) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.")
+ return
+ }
+
+ emulationActivity.runOnUiThread {
+ EmulationErrorDialogFragment.newInstance(resultCode).showNow(
+ emulationActivity.supportFragmentManager,
+ EmulationErrorDialogFragment.TAG
+ )
+ }
+ }
+
+ class EmulationErrorDialogFragment : DialogFragment() {
+ private lateinit var emulationActivity: EmulationActivity
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ emulationActivity = requireActivity() as EmulationActivity
+
+ var captionId = R.string.loader_error_invalid_format
+ if (requireArguments().getInt(RESULT_CODE) == ErrorLoader_ErrorEncrypted) {
+ captionId = R.string.loader_error_encrypted
+ }
+
+ val alert = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(captionId)
+ .setMessage(
+ Html.fromHtml(
+ CitraApplication.appContext.resources.getString(R.string.redump_games),
+ Html.FROM_HTML_MODE_LEGACY
+ )
+ )
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
+ emulationActivity.finish()
+ }
+ .create()
+ alert.show()
+
+ val alertMessage = alert.findViewById(android.R.id.message) as TextView
+ alertMessage.movementMethod = LinkMovementMethod.getInstance()
+
+ isCancelable = false
+ return alert
+ }
+
+ companion object {
+ const val TAG = "EmulationErrorDialogFragment"
+
+ const val RESULT_CODE = "resultcode"
+
+ const val Success = 0
+ const val ErrorNotInitialized = 1
+ const val ErrorGetLoader = 2
+ const val ErrorSystemMode = 3
+ const val ErrorLoader = 4
+ const val ErrorLoader_ErrorEncrypted = 5
+ const val ErrorLoader_ErrorInvalidFormat = 6
+ const val ErrorSystemFiles = 7
+ const val ShutdownRequested = 11
+ const val ErrorUnknown = 12
+
+ fun newInstance(resultCode: Int): EmulationErrorDialogFragment {
+ val args = Bundle()
+ args.putInt(RESULT_CODE, resultCode)
+ val fragment = EmulationErrorDialogFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+ }
+
+ fun setEmulationActivity(emulationActivity: EmulationActivity?) {
+ Log.verbose("[NativeLibrary] Registering EmulationActivity.")
+ sEmulationActivity = WeakReference(emulationActivity)
+ }
+
+ fun clearEmulationActivity() {
+ Log.verbose("[NativeLibrary] Unregistering EmulationActivity.")
+ sEmulationActivity.clear()
+ }
+
+ private val cameraPermissionLock = Object()
+ private var cameraPermissionGranted = false
+ const val REQUEST_CODE_NATIVE_CAMERA = 800
+
+ @Keep
+ @JvmStatic
+ fun requestCameraPermission(): Boolean {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present")
+ return false
+ }
+ if (ContextCompat.checkSelfPermission(emulationActivity, permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ // Permission already granted
+ return true
+ }
+ emulationActivity.requestPermissions(arrayOf(permission.CAMERA), REQUEST_CODE_NATIVE_CAMERA)
+
+ // Wait until result is returned
+ synchronized(cameraPermissionLock) {
+ try {
+ cameraPermissionLock.wait()
+ } catch (ignored: InterruptedException) {
+ }
+ }
+ return cameraPermissionGranted
+ }
+
+ fun cameraPermissionResult(granted: Boolean) {
+ cameraPermissionGranted = granted
+ synchronized(cameraPermissionLock) { cameraPermissionLock.notify() }
+ }
+
+ private val micPermissionLock = Object()
+ private var micPermissionGranted = false
+ const val REQUEST_CODE_NATIVE_MIC = 900
+
+ @Keep
+ @JvmStatic
+ fun requestMicPermission(): Boolean {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present")
+ return false
+ }
+ if (ContextCompat.checkSelfPermission(emulationActivity, permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ // Permission already granted
+ return true
+ }
+ emulationActivity.requestPermissions(
+ arrayOf(permission.RECORD_AUDIO),
+ REQUEST_CODE_NATIVE_MIC
+ )
+
+ // Wait until result is returned
+ synchronized(micPermissionLock) {
+ try {
+ micPermissionLock.wait()
+ } catch (ignored: InterruptedException) {
+ }
+ }
+ return micPermissionGranted
+ }
+
+ fun micPermissionResult(granted: Boolean) {
+ micPermissionGranted = granted
+ synchronized(micPermissionLock) { micPermissionLock.notify() }
+ }
+
+ // Notifies that the activity is now in foreground and camera devices can now be reloaded
+ external fun reloadCameraDevices()
+
+ external fun loadAmiibo(path: String?): Boolean
+
+ external fun removeAmiibo()
+
+ const val SAVESTATE_SLOT_COUNT = 10
+
+ external fun getSavestateInfo(): Array?
+
+ external fun saveState(slot: Int)
+
+ external fun loadState(slot: Int)
+
+ /**
+ * Logs the Citra version, Android version and, CPU.
+ */
+ external fun logDeviceInfo()
+
+ external fun loadSystemConfig()
+
+ external fun saveSystemConfig()
+
+ external fun setSystemSetupNeeded(needed: Boolean)
+
+ external fun getIsSystemSetupNeeded(): Boolean
+
+ @Keep
+ @JvmStatic
+ fun createFile(directory: String, filename: String): Boolean =
+ if (FileUtil.isNativePath(directory)) {
+ CitraApplication.documentsTree.createFile(directory, filename)
+ } else {
+ FileUtil.createFile(directory, filename) != null
+ }
+
+ @Keep
+ @JvmStatic
+ fun createDir(directory: String, directoryName: String): Boolean =
+ if (FileUtil.isNativePath(directory)) {
+ CitraApplication.documentsTree.createDir(directory, directoryName)
+ } else {
+ FileUtil.createDir(directory, directoryName) != null
+ }
+
+ @Keep
+ @JvmStatic
+ fun openContentUri(path: String, openMode: String): Int =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.openContentUri(path, openMode)
+ } else {
+ FileUtil.openContentUri(path, openMode)
+ }
+
+ @Keep
+ @JvmStatic
+ fun getFilesName(path: String): Array =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.getFilesName(path)
+ } else {
+ FileUtil.getFilesName(path)
+ }
+
+ @Keep
+ @JvmStatic
+ fun getSize(path: String): Long =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.getFileSize(path)
+ } else {
+ FileUtil.getFileSize(path)
+ }
+
+ @Keep
+ @JvmStatic
+ fun fileExists(path: String): Boolean =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.exists(path)
+ } else {
+ FileUtil.exists(path)
+ }
+
+ @Keep
+ @JvmStatic
+ fun isDirectory(path: String): Boolean =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.isDirectory(path)
+ } else {
+ FileUtil.isDirectory(path)
+ }
+
+ @Keep
+ @JvmStatic
+ fun copyFile(
+ sourcePath: String,
+ destinationParentPath: String,
+ destinationFilename: String
+ ): Boolean =
+ if (FileUtil.isNativePath(sourcePath) &&
+ FileUtil.isNativePath(destinationParentPath)
+ ) {
+ CitraApplication.documentsTree
+ .copyFile(sourcePath, destinationParentPath, destinationFilename)
+ } else {
+ FileUtil.copyFile(
+ Uri.parse(sourcePath),
+ Uri.parse(destinationParentPath),
+ destinationFilename
+ )
+ }
+
+ @Keep
+ @JvmStatic
+ fun renameFile(path: String, destinationFilename: String): Boolean =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.renameFile(path, destinationFilename)
+ } else {
+ FileUtil.renameFile(path, destinationFilename)
+ }
+
+ @Keep
+ @JvmStatic
+ fun deleteDocument(path: String): Boolean =
+ if (FileUtil.isNativePath(path)) {
+ CitraApplication.documentsTree.deleteDocument(path)
+ } else {
+ FileUtil.deleteDocument(path)
+ }
+
+ enum class CoreError {
+ ErrorSystemFiles,
+ ErrorSavestate,
+ ErrorUnknown
+ }
+
+ enum class InstallStatus {
+ Success,
+ ErrorFailedToOpenFile,
+ ErrorFileNotFound,
+ ErrorAborted,
+ ErrorInvalid,
+ ErrorEncrypted,
+ Cancelled
+ }
+
+ class CoreErrorDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val title = requireArguments().getString(TITLE)
+ val message = requireArguments().getString(MESSAGE)
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.continue_button) { _: DialogInterface?, _: Int ->
+ coreErrorAlertResult = true
+ }
+ .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
+ coreErrorAlertResult = false
+ }.show()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ super.onDismiss(dialog)
+ coreErrorAlertResult = true
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
+ }
+
+ companion object {
+ const val TAG = "CoreErrorDialogFragment"
+
+ const val TITLE = "title"
+ const val MESSAGE = "message"
+
+ fun newInstance(title: String, message: String): CoreErrorDialogFragment {
+ val frag = CoreErrorDialogFragment()
+ val args = Bundle()
+ args.putString(TITLE, title)
+ args.putString(MESSAGE, message)
+ frag.arguments = args
+ return frag
+ }
+ }
+ }
+
+ @Keep
+ class SaveStateInfo {
+ var slot = 0
+ var time: Date? = null
+ }
+
+ /**
+ * Button type for use in onTouchEvent
+ */
+ object ButtonType {
+ const val BUTTON_A = 700
+ const val BUTTON_B = 701
+ const val BUTTON_X = 702
+ const val BUTTON_Y = 703
+ const val BUTTON_START = 704
+ const val BUTTON_SELECT = 705
+ const val BUTTON_HOME = 706
+ const val BUTTON_ZL = 707
+ const val BUTTON_ZR = 708
+ const val DPAD_UP = 709
+ const val DPAD_DOWN = 710
+ const val DPAD_LEFT = 711
+ const val DPAD_RIGHT = 712
+ const val STICK_LEFT = 713
+ const val STICK_LEFT_UP = 714
+ const val STICK_LEFT_DOWN = 715
+ const val STICK_LEFT_LEFT = 716
+ const val STICK_LEFT_RIGHT = 717
+ const val STICK_C = 718
+ const val STICK_C_UP = 719
+ const val STICK_C_DOWN = 720
+ const val STICK_C_LEFT = 771
+ const val STICK_C_RIGHT = 772
+ const val TRIGGER_L = 773
+ const val TRIGGER_R = 774
+ const val DPAD = 780
+ const val BUTTON_DEBUG = 781
+ const val BUTTON_GPIO14 = 782
+ }
+
+ /**
+ * Button states
+ */
+ object ButtonState {
+ const val RELEASED = 0
+ const val PRESSED = 1
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
index 2e2d0d112..6a6075782 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
@@ -18,6 +18,7 @@ import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SubMenu;
import android.view.View;
+import android.view.WindowManager;
import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast;
@@ -48,6 +49,7 @@ import org.citra.citra_emu.utils.EmulationMenuSettings;
import org.citra.citra_emu.utils.FileBrowserHelper;
import org.citra.citra_emu.utils.FileUtil;
import org.citra.citra_emu.utils.ForegroundService;
+import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.ThemeUtil;
import java.io.File;
@@ -169,8 +171,8 @@ public final class EmulationActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
- ThemeUtil.applyTheme(this);
-
+ Log.gameLaunched = true;
+ ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState);
if (savedInstanceState == null) {
@@ -210,7 +212,7 @@ public final class EmulationActivity extends AppCompatActivity {
startForegroundService(foregroundService);
// Override Citra core INI with the one set by our in game menu
- NativeLibrary.SwapScreens(EmulationMenuSettings.getSwapScreens(),
+ NativeLibrary.INSTANCE.swapScreens(EmulationMenuSettings.getSwapScreens(),
getWindowManager().getDefaultDisplay().getRotation());
}
@@ -224,15 +226,12 @@ public final class EmulationActivity extends AppCompatActivity {
protected void restoreState(Bundle savedInstanceState) {
mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME);
mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE);
-
- // If an alert prompt was in progress when state was restored, retry displaying it
- NativeLibrary.retryDisplayAlertPrompt();
}
@Override
public void onRestart() {
super.onRestart();
- NativeLibrary.ReloadCameraDevices();
+ NativeLibrary.INSTANCE.reloadCameraDevices();
}
@Override
@@ -257,7 +256,7 @@ public final class EmulationActivity extends AppCompatActivity {
.setPositiveButton(android.R.string.ok, null)
.show();
}
- NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ NativeLibrary.INSTANCE.cameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
break;
case NativeLibrary.REQUEST_CODE_NATIVE_MIC:
if (grantResults[0] != PackageManager.PERMISSION_GRANTED &&
@@ -268,7 +267,7 @@ public final class EmulationActivity extends AppCompatActivity {
.setPositiveButton(android.R.string.ok, null)
.show();
}
- NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ NativeLibrary.INSTANCE.micPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
break;
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@@ -281,6 +280,10 @@ public final class EmulationActivity extends AppCompatActivity {
}
private void enableFullscreenImmersive() {
+ // TODO: Remove this once we properly account for display insets in the input overlay
+ getWindow().getAttributes().layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
+
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
@@ -323,7 +326,7 @@ public final class EmulationActivity extends AppCompatActivity {
}
private void DisplaySavestateWarning() {
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
if (preferences.getBoolean("savestateWarningShown", false)) {
return;
}
@@ -350,7 +353,7 @@ public final class EmulationActivity extends AppCompatActivity {
}
private void updateSavestateMenuOptions(Menu menu) {
- final NativeLibrary.SavestateInfo[] savestates = NativeLibrary.GetSavestateInfo();
+ final NativeLibrary.SaveStateInfo[] savestates = NativeLibrary.INSTANCE.getSavestateInfo();
if (savestates == null) {
menu.findItem(R.id.menu_emulation_save_state).setVisible(false);
menu.findItem(R.id.menu_emulation_load_state).setVisible(false);
@@ -370,18 +373,18 @@ public final class EmulationActivity extends AppCompatActivity {
final String text = getString(R.string.emulation_empty_state_slot, slot);
saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> {
DisplaySavestateWarning();
- NativeLibrary.SaveState(slot);
+ NativeLibrary.INSTANCE.saveState(slot);
return true;
});
loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> {
- NativeLibrary.LoadState(slot);
+ NativeLibrary.INSTANCE.loadState(slot);
return true;
});
}
- for (final NativeLibrary.SavestateInfo info : savestates) {
- final String text = getString(R.string.emulation_occupied_state_slot, info.slot, info.time);
- saveStateMenu.getItem(info.slot - 1).setTitle(text);
- loadStateMenu.getItem(info.slot - 1).setTitle(text).setEnabled(true);
+ for (final NativeLibrary.SaveStateInfo info : savestates) {
+ final String text = getString(R.string.emulation_occupied_state_slot, info.getSlot(), info.getTime());
+ saveStateMenu.getItem(info.getSlot() - 1).setTitle(text);
+ loadStateMenu.getItem(info.getSlot() - 1).setTitle(text).setEnabled(true);
}
}
@@ -441,7 +444,7 @@ public final class EmulationActivity extends AppCompatActivity {
EmulationMenuSettings.setSwapScreens(isEnabled);
item.setChecked(isEnabled);
- NativeLibrary.SwapScreens(isEnabled, getWindowManager().getDefaultDisplay()
+ NativeLibrary.INSTANCE.swapScreens(isEnabled, getWindowManager().getDefaultDisplay()
.getRotation());
break;
}
@@ -491,11 +494,11 @@ public final class EmulationActivity extends AppCompatActivity {
break;
case MENU_ACTION_OPEN_CHEATS:
- CheatsActivity.launch(this, NativeLibrary.GetRunningTitleId());
+ CheatsActivity.launch(this, NativeLibrary.INSTANCE.getRunningTitleId());
break;
case MENU_ACTION_CLOSE_GAME:
- NativeLibrary.PauseEmulation();
+ NativeLibrary.INSTANCE.pauseEmulation();
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.emulation_close_game)
.setMessage(R.string.emulation_close_game_message)
@@ -504,8 +507,8 @@ public final class EmulationActivity extends AppCompatActivity {
mEmulationFragment.stopEmulation();
finish();
})
- .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.UnPauseEmulation())
- .setOnCancelListener(dialogInterface -> NativeLibrary.UnPauseEmulation())
+ .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.INSTANCE.unPauseEmulation())
+ .setOnCancelListener(dialogInterface -> NativeLibrary.INSTANCE.unPauseEmulation())
.show();
break;
}
@@ -515,7 +518,7 @@ public final class EmulationActivity extends AppCompatActivity {
private void changeScreenOrientation(int layoutOption, MenuItem item) {
item.setChecked(true);
- NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
+ NativeLibrary.INSTANCE.notifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
.getRotation());
EmulationMenuSettings.setLandscapeScreenLayout(layoutOption);
}
@@ -558,7 +561,7 @@ public final class EmulationActivity extends AppCompatActivity {
return false;
}
- return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action);
+ return NativeLibrary.INSTANCE.onGamePadEvent(input.getDescriptor(), button, action);
}
@Override
@@ -570,7 +573,7 @@ public final class EmulationActivity extends AppCompatActivity {
}
private void onAmiiboSelected(String selectedFile) {
- boolean success = NativeLibrary.LoadAmiibo(selectedFile);
+ boolean success = NativeLibrary.INSTANCE.loadAmiibo(selectedFile);
if (!success) {
new MaterialAlertDialogBuilder(this)
@@ -582,7 +585,7 @@ public final class EmulationActivity extends AppCompatActivity {
}
private void RemoveAmiibo() {
- NativeLibrary.RemoveAmiibo();
+ NativeLibrary.INSTANCE.removeAmiibo();
}
private void toggleControls() {
@@ -725,47 +728,47 @@ public final class EmulationActivity extends AppCompatActivity {
}
// Circle-Pad and C-Stick status
- NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]);
- NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]);
+ NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]);
+ NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]);
// Triggers L/R and ZL/ZR
if (isTriggerPressedLMapped) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
}
if (isTriggerPressedRMapped) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
}
if (isTriggerPressedZLMapped) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
}
if (isTriggerPressedZRMapped) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
}
// Work-around to allow D-pad axis to be bound to emulated buttons
if (axisValuesDPad[0] == 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
}
if (axisValuesDPad[0] < 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
}
if (axisValuesDPad[0] > 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED);
}
if (axisValuesDPad[1] == 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
}
if (axisValuesDPad[1] < 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
}
if (axisValuesDPad[1] > 0.f) {
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED);
}
return true;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt
new file mode 100644
index 000000000..835c01524
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt
@@ -0,0 +1,119 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.adapters
+
+import android.net.Uri
+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.citra.citra_emu.R
+import org.citra.citra_emu.databinding.CardDriverOptionBinding
+import org.citra.citra_emu.utils.GpuDriverMetadata
+import org.citra.citra_emu.viewmodel.DriverViewModel
+import org.citra.citra_emu.utils.GpuDriverHelper
+
+class DriverAdapter(private val driverViewModel: DriverViewModel) :
+ ListAdapter, 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, 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
+
+ fun bind(driverData: Pair) {
+ 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>() {
+ override fun areItemsTheSame(
+ oldItem: Pair,
+ newItem: Pair
+ ): Boolean {
+ return oldItem.first == newItem.first
+ }
+
+ override fun areContentsTheSame(
+ oldItem: Pair,
+ newItem: Pair
+ ): Boolean {
+ return oldItem.second == newItem.second
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
deleted file mode 100644
index 1c3cad9b1..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
+++ /dev/null
@@ -1,261 +0,0 @@
-package org.citra.citra_emu.adapters;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.database.DataSetObserver;
-import android.os.Build;
-import android.os.SystemClock;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RequiresApi;
-import androidx.fragment.app.FragmentActivity;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.color.MaterialColors;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.NativeLibrary;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.activities.EmulationActivity;
-import org.citra.citra_emu.features.cheats.ui.CheatsActivity;
-import org.citra.citra_emu.model.GameDatabase;
-import org.citra.citra_emu.utils.FileUtil;
-import org.citra.citra_emu.utils.Log;
-import org.citra.citra_emu.utils.PicassoUtils;
-import org.citra.citra_emu.viewholders.GameViewHolder;
-
-import java.util.stream.Stream;
-
-/**
- * This adapter gets its information from a database Cursor. This fact, paired with the usage of
- * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
- * large dataset.
- */
-public final class GameAdapter extends RecyclerView.Adapter {
- private Cursor mCursor;
- private GameDataSetObserver mObserver;
-
- private boolean mDatasetValid;
- private long mLastClickTime = 0;
-
- /**
- * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will
- * display no data until a Cursor is supplied by a CursorLoader.
- */
- public GameAdapter() {
- mDatasetValid = false;
- mObserver = new GameDataSetObserver();
- }
-
- /**
- * Called by the LayoutManager when it is necessary to create a new view.
- *
- * @param parent The RecyclerView (I think?) the created view will be thrown into.
- * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView.
- * @return The created ViewHolder with references to all the child view's members.
- */
- @Override
- public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- // Create a new view.
- View gameCard = LayoutInflater.from(parent.getContext())
- .inflate(R.layout.card_game, parent, false);
-
- gameCard.setOnClickListener(this::onClick);
- gameCard.setOnLongClickListener(this::onLongClick);
-
- // Use that view to create a ViewHolder.
- return new GameViewHolder(gameCard);
- }
-
- /**
- * Called by the LayoutManager when a new view is not necessary because we can recycle
- * an existing one (for example, if a view just scrolled onto the screen from the bottom, we
- * can use the view that just scrolled off the top instead of inflating a new one.)
- *
- * @param holder A ViewHolder representing the view we're recycling.
- * @param position The position of the 'new' view in the dataset.
- */
- @RequiresApi(api = Build.VERSION_CODES.O)
- @Override
- public void onBindViewHolder(@NonNull GameViewHolder holder, int position) {
- if (mDatasetValid) {
- if (mCursor.moveToPosition(position)) {
- PicassoUtils.loadGameIcon(holder.imageIcon,
- mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
-
- holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " "));
- holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
-
- String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH);
- String filename;
- if (FileUtil.isNativePath(filepath)) {
- filename = CitraApplication.documentsTree.getFilename(filepath);
- } else {
- filename = FileUtil.getFilename(CitraApplication.getAppContext(), filepath);
- }
- holder.textFileName.setText(filename);
-
- // TODO These shouldn't be necessary once the move to a DB-based model is complete.
- holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID);
- holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH);
- holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE);
- holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION);
- holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS);
- holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY);
-
- final int backgroundColorId = isValidGame(holder.path) ? R.attr.colorSurface : R.attr.colorErrorContainer;
- View itemView = holder.getItemView();
- itemView.setBackgroundColor(MaterialColors.getColor(itemView, backgroundColorId));
- } else {
- Log.error("[GameAdapter] Can't bind view; Cursor is not valid.");
- }
- } else {
- Log.error("[GameAdapter] Can't bind view; dataset is not valid.");
- }
- }
-
- /**
- * Called by the LayoutManager to find out how much data we have.
- *
- * @return Size of the dataset.
- */
- @Override
- public int getItemCount() {
- if (mDatasetValid && mCursor != null) {
- return mCursor.getCount();
- }
- Log.error("[GameAdapter] Dataset is not valid.");
- return 0;
- }
-
- /**
- * Return the contents of the _id column for a given row.
- *
- * @param position The row for which Android wants an ID.
- * @return A valid ID from the database, or 0 if not available.
- */
- @Override
- public long getItemId(int position) {
- if (mDatasetValid && mCursor != null) {
- if (mCursor.moveToPosition(position)) {
- return mCursor.getLong(GameDatabase.COLUMN_DB_ID);
- }
- }
-
- Log.error("[GameAdapter] Dataset is not valid.");
- return 0;
- }
-
- /**
- * Tell Android whether or not each item in the dataset has a stable identifier.
- * Which it does, because it's a database, so always tell Android 'true'.
- *
- * @param hasStableIds ignored.
- */
- @Override
- public void setHasStableIds(boolean hasStableIds) {
- super.setHasStableIds(true);
- }
-
- /**
- * When a load is finished, call this to replace the existing data with the newly-loaded
- * data.
- *
- * @param cursor The newly-loaded Cursor.
- */
- public void swapCursor(Cursor cursor) {
- // Sanity check.
- if (cursor == mCursor) {
- return;
- }
-
- // Before getting rid of the old cursor, disassociate it from the Observer.
- final Cursor oldCursor = mCursor;
- if (oldCursor != null && mObserver != null) {
- oldCursor.unregisterDataSetObserver(mObserver);
- }
-
- mCursor = cursor;
- if (mCursor != null) {
- // Attempt to associate the new Cursor with the Observer.
- if (mObserver != null) {
- mCursor.registerDataSetObserver(mObserver);
- }
-
- mDatasetValid = true;
- } else {
- mDatasetValid = false;
- }
-
- notifyDataSetChanged();
- }
-
- /**
- * Launches the game that was clicked on.
- *
- * @param view The view representing the game the user wants to play.
- */
- private void onClick(View view) {
- // Double-click prevention, using threshold of 1000 ms
- if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) {
- return;
- }
- mLastClickTime = SystemClock.elapsedRealtime();
-
- GameViewHolder holder = (GameViewHolder) view.getTag();
-
- EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title);
- }
-
- /**
- * Opens the cheats settings for the game that was clicked on.
- *
- * @param view The view representing the game the user wants to play.
- */
- private boolean onLongClick(View view) {
- Context context = view.getContext();
- GameViewHolder holder = (GameViewHolder) view.getTag();
-
- final long titleId = NativeLibrary.GetTitleId(holder.path);
-
- if (titleId == 0) {
- new MaterialAlertDialogBuilder(context)
- .setIcon(R.mipmap.ic_launcher)
- .setTitle(R.string.properties)
- .setMessage(R.string.properties_not_loaded)
- .setPositiveButton(android.R.string.ok, null)
- .show();
- } else {
- CheatsActivity.launch(context, titleId);
- }
-
- return true;
- }
-
- private boolean isValidGame(String path) {
- return Stream.of(
- ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix));
- }
-
- private final class GameDataSetObserver extends DataSetObserver {
- @Override
- public void onChanged() {
- super.onChanged();
-
- mDatasetValid = true;
- notifyDataSetChanged();
- }
-
- @Override
- public void onInvalidated() {
- super.onInvalidated();
-
- mDatasetValid = false;
- notifyDataSetChanged();
- }
- }
-}
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
new file mode 100644
index 000000000..b507ea5bd
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt
@@ -0,0 +1,203 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.adapters
+
+import android.net.Uri
+import android.os.SystemClock
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModelProvider
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.activities.EmulationActivity
+import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder
+import org.citra.citra_emu.databinding.CardGameBinding
+import org.citra.citra_emu.features.cheats.ui.CheatsActivity
+import org.citra.citra_emu.model.Game
+import org.citra.citra_emu.utils.GameIconUtils
+import org.citra.citra_emu.viewmodel.GamesViewModel
+
+class GameAdapter(private val activity: AppCompatActivity) :
+ ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()),
+ View.OnClickListener, View.OnLongClickListener {
+ private var lastClickTime = 0L
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
+ // Create a new view.
+ val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.cardGame.setOnClickListener(this)
+ binding.cardGame.setOnLongClickListener(this)
+
+ // Use that view to create a ViewHolder.
+ return GameViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ override fun getItemCount(): Int = currentList.size
+
+ /**
+ * Launches the game that was clicked on.
+ *
+ * @param view The card representing the game the user wants to play.
+ */
+ override fun onClick(view: View) {
+ // Double-click prevention, using threshold of 1000 ms
+ if (SystemClock.elapsedRealtime() - lastClickTime < 1000) {
+ return
+ }
+ lastClickTime = SystemClock.elapsedRealtime()
+
+ val holder = view.tag as GameViewHolder
+ gameExists(holder)
+
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ preferences.edit()
+ .putLong(
+ holder.game.keyLastPlayedTime,
+ System.currentTimeMillis()
+ )
+ .apply()
+
+ EmulationActivity.launch(activity, holder.game.path, holder.game.title)
+ }
+
+ /**
+ * Opens the cheats settings for the game that was clicked on.
+ *
+ * @param view The view representing the game the user wants to play.
+ */
+ override fun onLongClick(view: View): Boolean {
+ val context = view.context
+ val holder = view.tag as GameViewHolder
+ gameExists(holder)
+
+ if (holder.game.titleId == 0L) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.properties)
+ .setMessage(R.string.properties_not_loaded)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ } else {
+ CheatsActivity.launch(view.context, holder.game.titleId)
+ }
+ return true
+ }
+
+ // Triggers a library refresh if the user clicks on stale data
+ private fun gameExists(holder: GameViewHolder): Boolean {
+ if (holder.game.isInstalled) {
+ return true
+ }
+
+ val gameExists = DocumentFile.fromSingleUri(
+ CitraApplication.appContext,
+ Uri.parse(holder.game.path)
+ )?.exists() == true
+ return if (!gameExists) {
+ Toast.makeText(
+ CitraApplication.appContext,
+ R.string.loader_error_file_not_found,
+ Toast.LENGTH_LONG
+ ).show()
+
+ ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+ false
+ } else {
+ true
+ }
+ }
+
+ inner class GameViewHolder(val binding: CardGameBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var game: Game
+
+ init {
+ binding.cardGame.tag = this
+ }
+
+ fun bind(game: Game) {
+ this.game = game
+
+ binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
+ GameIconUtils.loadGameIcon(activity, game, binding.imageGameScreen)
+
+ binding.textGameTitle.visibility = if (game.title.isEmpty()) {
+ View.GONE
+ } else {
+ View.VISIBLE
+ }
+ binding.textCompany.visibility = if (game.company.isEmpty()) {
+ View.GONE
+ } else {
+ View.VISIBLE
+ }
+
+ binding.textGameTitle.text = game.title
+ binding.textCompany.text = game.company
+ binding.textFilename.text = game.filename
+
+ val backgroundColorId =
+ if (
+ isValidGame(game.filename.substring(game.filename.lastIndexOf(".") + 1).lowercase())
+ ) {
+ R.attr.colorSurface
+ } else {
+ R.attr.colorErrorContainer
+ }
+ binding.cardContents.setBackgroundColor(
+ MaterialColors.getColor(
+ binding.cardContents,
+ backgroundColorId
+ )
+ )
+
+ binding.textGameTitle.postDelayed(
+ {
+ binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.textGameTitle.isSelected = true
+
+ binding.textCompany.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.textCompany.isSelected = true
+
+ binding.textFilename.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.textFilename.isSelected = true
+ },
+ 3000
+ )
+ }
+ }
+
+ private fun isValidGame(extension: String): Boolean {
+ return Game.badExtensions.stream()
+ .noneMatch { extension == it.lowercase() }
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem.titleId == newItem.titleId
+ }
+
+ override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt
new file mode 100644
index 000000000..f90c65e77
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt
@@ -0,0 +1,112 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.adapters
+
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.R
+import org.citra.citra_emu.databinding.CardHomeOptionBinding
+import org.citra.citra_emu.fragments.MessageDialogFragment
+import org.citra.citra_emu.model.HomeSetting
+import org.citra.citra_emu.viewmodel.GamesViewModel
+
+class HomeSettingAdapter(
+ private val activity: AppCompatActivity,
+ private val viewLifecycle: LifecycleOwner,
+ var options: List
+) : RecyclerView.Adapter(), View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
+ val binding =
+ CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return HomeOptionViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int {
+ return options.size
+ }
+
+ override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
+ holder.bind(options[position])
+ }
+
+ override fun onClick(view: View) {
+ val holder = view.tag as HomeOptionViewHolder
+ if (holder.option.isEnabled.invoke()) {
+ holder.option.onClick.invoke()
+ } else {
+ MessageDialogFragment.newInstance(
+ holder.option.disabledTitleId,
+ holder.option.disabledMessageId
+ ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+
+ inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var option: HomeSetting
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(option: HomeSetting) {
+ this.option = option
+
+ binding.optionTitle.text = activity.resources.getString(option.titleId)
+ binding.optionDescription.text = activity.resources.getString(option.descriptionId)
+ binding.optionIcon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ option.iconId,
+ activity.theme
+ )
+ )
+
+ viewLifecycle.lifecycleScope.launch {
+ viewLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
+ option.details.collect { updateOptionDetails(it) }
+ }
+ }
+ binding.optionDetail.postDelayed(
+ {
+ binding.optionDetail.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.optionDetail.isSelected = true
+ },
+ 3000
+ )
+
+ if (option.isEnabled.invoke()) {
+ binding.optionTitle.alpha = 1f
+ binding.optionDescription.alpha = 1f
+ binding.optionIcon.alpha = 1f
+ } else {
+ binding.optionTitle.alpha = 0.5f
+ binding.optionDescription.alpha = 0.5f
+ binding.optionIcon.alpha = 0.5f
+ }
+ }
+
+ private fun updateOptionDetails(detailString: String) {
+ if (detailString != "") {
+ binding.optionDetail.text = detailString
+ binding.optionDetail.visibility = View.VISIBLE
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt
new file mode 100644
index 000000000..dd3f4debe
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt
@@ -0,0 +1,55 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.databinding.ListItemSettingBinding
+import org.citra.citra_emu.fragments.LicenseBottomSheetDialogFragment
+import org.citra.citra_emu.model.License
+
+class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List) :
+ RecyclerView.Adapter(),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
+ val binding =
+ ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return LicenseViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = licenses.size
+
+ override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) {
+ holder.bind(licenses[position])
+ }
+
+ override fun onClick(view: View) {
+ val license = (view.tag as LicenseViewHolder).license
+ LicenseBottomSheetDialogFragment.newInstance(license)
+ .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
+ }
+
+ inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) {
+ lateinit var license: License
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(license: License) {
+ this.license = license
+
+ val context = CitraApplication.appContext
+ binding.textSettingName.text = context.getString(license.titleId)
+ binding.textSettingDescription.text = context.getString(license.descriptionId)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt
new file mode 100644
index 000000000..b6917b072
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt
@@ -0,0 +1,87 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.adapters
+
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import org.citra.citra_emu.databinding.PageSetupBinding
+import org.citra.citra_emu.model.SetupCallback
+import org.citra.citra_emu.model.SetupPage
+import org.citra.citra_emu.model.StepState
+import org.citra.citra_emu.utils.ViewUtils
+
+class SetupAdapter(val activity: AppCompatActivity, val pages: List) :
+ RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
+ val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return SetupPageViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = pages.size
+
+ override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
+ holder.bind(pages[position])
+
+ inner class SetupPageViewHolder(val binding: PageSetupBinding) :
+ RecyclerView.ViewHolder(binding.root), SetupCallback {
+ lateinit var page: SetupPage
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(page: SetupPage) {
+ this.page = page
+
+ if (page.stepCompleted.invoke() == StepState.STEP_COMPLETE) {
+ onStepCompleted()
+ }
+
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ page.iconId,
+ activity.theme
+ )
+ )
+ binding.textTitle.text = activity.resources.getString(page.titleId)
+ binding.textDescription.text =
+ Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
+ binding.textDescription.movementMethod = LinkMovementMethod.getInstance()
+
+ binding.buttonAction.apply {
+ text = activity.resources.getString(page.buttonTextId)
+ if (page.buttonIconId != 0) {
+ icon = ResourcesCompat.getDrawable(
+ activity.resources,
+ page.buttonIconId,
+ activity.theme
+ )
+ }
+ iconGravity =
+ if (page.leftAlignedIcon) {
+ MaterialButton.ICON_GRAVITY_START
+ } else {
+ MaterialButton.ICON_GRAVITY_END
+ }
+ setOnClickListener {
+ page.buttonAction.invoke(this@SetupPageViewHolder)
+ }
+ }
+ }
+
+ override fun onStepCompleted() {
+ ViewUtils.hideView(binding.buttonAction, 200)
+ ViewUtils.showView(binding.textConfirmation, 200)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java
index d6fce6a30..67f51bc6d 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java
@@ -18,13 +18,16 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Objects;
+import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+@Keep
public final class MiiSelector {
+ @Keep
public static class MiiSelectorConfig implements java.io.Serializable {
public boolean enable_cancel_button;
public String title;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java
index 800ecaf86..77b02a6f0 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java
@@ -7,13 +7,17 @@ package org.citra.citra_emu.applets;
import android.app.Activity;
import android.app.Dialog;
import android.content.DialogInterface;
+import android.content.res.Resources;
import android.os.Bundle;
import android.text.InputFilter;
import android.text.Spanned;
+import android.util.TypedValue;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.FrameLayout;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
@@ -29,6 +33,7 @@ import org.citra.citra_emu.utils.Log;
import java.util.Objects;
+@Keep
public final class SoftwareKeyboard {
/// Corresponds to Frontend::ButtonConfig
private interface ButtonConfig {
@@ -57,6 +62,7 @@ public final class SoftwareKeyboard {
EmptyInputNotAllowed,
}
+ @Keep
public static class KeyboardConfig implements java.io.Serializable {
public int button_config;
public int max_text_length;
@@ -109,20 +115,27 @@ public final class SoftwareKeyboard {
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.leftMargin = params.rightMargin =
- CitraApplication.getAppContext().getResources().getDimensionPixelSize(
+ CitraApplication.Companion.getAppContext().getResources().getDimensionPixelSize(
R.dimen.dialog_margin);
KeyboardConfig config = Objects.requireNonNull(
(KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config"));
// Set up the input
- EditText editText = new EditText(CitraApplication.getAppContext());
+ EditText editText = new EditText(CitraApplication.Companion.getAppContext());
editText.setHint(config.hint_text);
editText.setSingleLine(!config.multiline_mode);
editText.setLayoutParams(params);
editText.setFilters(new InputFilter[]{
new Filter(), new InputFilter.LengthFilter(config.max_text_length)});
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = requireContext().getTheme();
+ theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true);
+ @ColorInt int color = typedValue.data;
+ editText.setHintTextColor(color);
+ editText.setTextColor(color);
+
FrameLayout container = new FrameLayout(emulationActivity);
container.addView(editText);
@@ -256,7 +269,7 @@ public final class SoftwareKeyboard {
public static void ShowError(String error) {
NativeLibrary.displayAlertMsg(
- CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard),
+ CitraApplication.Companion.getAppContext().getResources().getString(R.string.software_keyboard),
error, false);
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java
index 701cb0710..55be2660a 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java
@@ -13,6 +13,7 @@ import org.citra.citra_emu.R;
import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.utils.PicassoUtils;
+import androidx.annotation.Keep;
import androidx.annotation.Nullable;
// Used in native code.
@@ -23,6 +24,7 @@ public final class StillImageCameraHelper {
String filePickerPath;
// Opens file picker for camera.
+ @Keep
public static @Nullable
String OpenFilePicker() {
final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
@@ -58,6 +60,7 @@ public final class StillImageCameraHelper {
}
// Blocking call. Load image from file and crop/resize it to fit in width x height.
+ @Keep
@Nullable
public static Bitmap LoadImageFromFile(String uri, int width, int height) {
return PicassoUtils.LoadBitmapFromFile(uri, width, height);
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java
deleted file mode 100644
index 7d70e94b4..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java
+++ /dev/null
@@ -1,91 +0,0 @@
-package org.citra.citra_emu.dialogs;
-
-import android.app.Dialog;
-import android.content.SharedPreferences;
-import android.net.Uri;
-import android.os.Bundle;
-import android.preference.PreferenceManager;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.widget.CheckBox;
-import android.widget.TextView;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.fragment.app.DialogFragment;
-import androidx.fragment.app.FragmentActivity;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import java.util.Objects;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.utils.FileUtil;
-import org.citra.citra_emu.utils.PermissionsHandler;
-
-public class CitraDirectoryDialog extends DialogFragment {
- public static final String TAG = "citra_directory_dialog_fragment";
-
- private static final String MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE";
-
- TextView pathView;
-
- TextView spaceView;
-
- CheckBox checkBox;
-
- AlertDialog dialog;
-
- Listener listener;
-
- public interface Listener {
- void onPressPositiveButton(boolean moveData, Uri path);
- }
-
- public static CitraDirectoryDialog newInstance(String path, Listener listener) {
- CitraDirectoryDialog frag = new CitraDirectoryDialog();
- frag.listener = listener;
- Bundle args = new Bundle();
- args.putString("path", path);
- frag.setArguments(args);
- return frag;
- }
-
- @NonNull
- @Override
- public Dialog onCreateDialog(Bundle savedInstanceState) {
- final FragmentActivity activity = requireActivity();
- final Uri path = Uri.parse(Objects.requireNonNull(requireArguments().getString("path")));
- SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(activity);
- String freeSpaceText =
- getResources().getString(R.string.free_space, FileUtil.getFreeSpace(activity, path));
-
- LayoutInflater inflater = getLayoutInflater();
- View view = inflater.inflate(R.layout.dialog_citra_directory, null);
-
- checkBox = view.findViewById(R.id.checkBox);
- pathView = view.findViewById(R.id.path);
- spaceView = view.findViewById(R.id.space);
-
- checkBox.setChecked(mPreferences.getBoolean(MOVE_DATE_ENABLE, true));
- if (!PermissionsHandler.hasWriteAccess(activity)) {
- checkBox.setVisibility(View.GONE);
- }
- checkBox.setOnCheckedChangeListener(
- (v, isChecked)
- // record move data selection with SharedPreferences
- -> mPreferences.edit().putBoolean(MOVE_DATE_ENABLE, checkBox.isChecked()).apply());
-
- pathView.setText(path.getPath());
- spaceView.setText(freeSpaceText);
-
- setCancelable(false);
-
- dialog = new MaterialAlertDialogBuilder(activity)
- .setView(view)
- .setIcon(R.mipmap.ic_launcher)
- .setTitle(R.string.app_name)
- .setPositiveButton(
- android.R.string.ok,
- (d, v) -> listener.onPressPositiveButton(checkBox.isChecked(), path))
- .setNegativeButton(android.R.string.cancel, null)
- .create();
- return dialog;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java
deleted file mode 100644
index f13e626ee..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.citra.citra_emu.dialogs;
-
-import android.app.Dialog;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.fragment.app.DialogFragment;
-import androidx.fragment.app.FragmentActivity;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import org.citra.citra_emu.R;
-
-public class CopyDirProgressDialog extends DialogFragment {
- public static final String TAG = "copy_dir_progress_dialog";
- ProgressBar progressBar;
-
- TextView progressText;
-
- AlertDialog dialog;
-
- @NonNull
- @Override
- public Dialog onCreateDialog(Bundle savedInstanceState) {
- final FragmentActivity activity = requireActivity();
-
- LayoutInflater inflater = getLayoutInflater();
- View view = inflater.inflate(R.layout.dialog_progress_bar, null);
-
- progressBar = view.findViewById(R.id.progress_bar);
- progressText = view.findViewById(R.id.progress_text);
- progressText.setText("");
-
- setCancelable(false);
-
- dialog = new MaterialAlertDialogBuilder(activity)
- .setView(view)
- .setIcon(R.mipmap.ic_launcher)
- .setTitle(R.string.move_data)
- .setMessage("")
- .create();
- return dialog;
- }
-
- public void onUpdateSearchProgress(String msg) {
- requireActivity().runOnUiThread(() -> {
- dialog.setMessage(getResources().getString(R.string.searching_direcotry, msg));
- });
- }
-
- public void onUpdateCopyProgress(String msg, int progress, int max) {
- requireActivity().runOnUiThread(() -> {
- progressBar.setProgress(progress);
- progressBar.setMax(max);
- progressText.setText(String.format("%d/%d", progress, max));
- dialog.setMessage(getResources().getString(R.string.copy_file_name, msg));
- });
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java
index 45d0daf5b..9446d1ad9 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java
@@ -51,8 +51,7 @@ public class CheatsActivity extends AppCompatActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
- ThemeUtil.applyTheme(this);
-
+ ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState);
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java
index 9684966f2..997dd1e26 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java
@@ -14,7 +14,12 @@ import java.util.Map;
import java.util.TreeMap;
public class Settings {
- public static final String SECTION_PREMIUM = "Premium";
+ public static final String PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch";
+ public static final String PREF_MATERIAL_YOU = "MaterialYouTheme";
+ public static final String PREF_THEME_MODE = "ThemeMode";
+ public static final String PREF_BLACK_BACKGROUNDS = "BlackBackgrounds";
+ public static final String PREF_SHOW_HOME_APPS = "ShowHomeApps";
+
public static final String SECTION_CORE = "Core";
public static final String SECTION_SYSTEM = "System";
public static final String SECTION_CAMERA = "Camera";
@@ -30,7 +35,7 @@ public class Settings {
private static final Map> configFileSectionsMap = new HashMap<>();
static {
- configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG));
+ configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG));
}
/**
@@ -109,7 +114,7 @@ public class Settings {
public void saveSettings(SettingsActivityView view) {
if (TextUtils.isEmpty(gameId)) {
- view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false);
+ view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.ini_saved), false);
for (Map.Entry> entry : configFileSectionsMap.entrySet()) {
String fileName = entry.getKey();
@@ -121,12 +126,6 @@ public class Settings {
SettingsFile.saveFile(fileName, iniSections, view);
}
- } else {
- // custom game settings
- view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false);
-
- SettingsFile.saveCustomGameSettings(gameId, sections);
}
-
}
-}
\ No newline at end of file
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java
index baf40709f..6bafecfe0 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java
@@ -59,7 +59,7 @@ public final class CheckBoxSetting extends SettingsItem {
public IntSetting setChecked(boolean checked) {
// Show a performance warning if the setting has been disabled
if (mShowPerformanceWarning && !checked) {
- mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true);
+ mView.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.performance_warning), true);
}
if (getSetting() == null) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java
index e9141a208..6d4d954e8 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java
@@ -201,7 +201,7 @@ public final class InputBindingSetting extends SettingsItem {
*/
public void removeOldMapping() {
// Get preferences editor
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit();
// Try remove all possible keys we wrote for this setting
@@ -250,7 +250,7 @@ public final class InputBindingSetting extends SettingsItem {
*/
private void WriteButtonMapping(String key) {
// Get preferences editor
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit();
// Remove mapping for another setting using this input
@@ -278,7 +278,7 @@ public final class InputBindingSetting extends SettingsItem {
*/
private void WriteAxisMapping(int axis, int value) {
// Get preferences editor
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit();
// Cleanup old mapping
@@ -302,7 +302,7 @@ public final class InputBindingSetting extends SettingsItem {
*/
public void onKeyInput(KeyEvent keyEvent) {
if (!IsButtonMappingSupported()) {
- Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show();
+ Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show();
return;
}
@@ -324,11 +324,11 @@ public final class InputBindingSetting extends SettingsItem {
public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange,
char axisDir) {
if (!IsAxisMappingSupported()) {
- Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show();
+ Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show();
return;
}
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit();
int button;
@@ -354,7 +354,7 @@ public final class InputBindingSetting extends SettingsItem {
* Sets the string to use in the configuration UI for the gamepad input.
*/
private StringSetting setUiString(String ui) {
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit();
if (getSetting() == null) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java
deleted file mode 100644
index 8942bf13a..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.citra.citra_emu.features.settings.model.view;
-
-import org.citra.citra_emu.features.settings.model.Setting;
-
-public final class PremiumHeader extends SettingsItem {
- public PremiumHeader() {
- super(null, null, null, 0, 0);
- }
-
- @Override
- public int getType() {
- return SettingsItem.TYPE_PREMIUM;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java
deleted file mode 100644
index c0560d2dc..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package org.citra.citra_emu.features.settings.model.view;
-
-import android.content.SharedPreferences;
-import android.preference.PreferenceManager;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.model.Setting;
-import org.citra.citra_emu.features.settings.ui.SettingsFragmentView;
-
-public final class PremiumSingleChoiceSetting extends SettingsItem {
- private int mDefaultValue;
-
- private int mChoicesId;
- private int mValuesId;
- private SettingsFragmentView mView;
-
- private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
-
- public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId,
- int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) {
- super(key, section, setting, titleId, descriptionId);
- mValuesId = valuesId;
- mChoicesId = choicesId;
- mDefaultValue = defaultValue;
- mView = view;
- }
-
- public int getChoicesId() {
- return mChoicesId;
- }
-
- public int getValuesId() {
- return mValuesId;
- }
-
- public int getSelectedValue() {
- return mPreferences.getInt(getKey(), mDefaultValue);
- }
-
- /**
- * Write a value to the backing int. If that int was previously null,
- * initializes a new one and returns it, so it can be added to the Hashmap.
- *
- * @param selection New value of the int.
- * @return null if overwritten successfully otherwise; a newly created IntSetting.
- */
- public void setSelectedValue(int selection) {
- final SharedPreferences.Editor editor = mPreferences.edit();
- editor.putInt(getKey(), selection);
- editor.apply();
- mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false);
- }
-
- @Override
- public int getType() {
- return TYPE_SINGLE_CHOICE;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java
index 305352022..8a5642696 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java
@@ -20,7 +20,6 @@ public abstract class SettingsItem {
public static final int TYPE_INPUT_BINDING = 5;
public static final int TYPE_STRING_SINGLE_CHOICE = 6;
public static final int TYPE_DATETIME_SETTING = 7;
- public static final int TYPE_PREMIUM = 8;
private String mKey;
private String mSection;
@@ -29,7 +28,6 @@ public abstract class SettingsItem {
private int mNameId;
private int mDescriptionId;
- private boolean mIsPremium;
/**
* Base constructor. Takes a key / section name in case the third parameter, the Setting,
@@ -48,7 +46,6 @@ public abstract class SettingsItem {
mSetting = setting;
mNameId = nameId;
mDescriptionId = descriptionId;
- mIsPremium = (section == Settings.SECTION_PREMIUM);
}
/**
@@ -93,10 +90,6 @@ public abstract class SettingsItem {
return mDescriptionId;
}
- public boolean isPremium() {
- return mIsPremium;
- }
-
/**
* Used by {@link SettingsAdapter}'s onCreateViewHolder()
* method to determine which type of ViewHolder should be created.
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java
index 19aacb7f5..58ffbbfea 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java
@@ -26,7 +26,6 @@ import com.google.android.material.appbar.MaterialToolbar;
import org.citra.citra_emu.NativeLibrary;
import org.citra.citra_emu.R;
import org.citra.citra_emu.utils.DirectoryInitialization;
-import org.citra.citra_emu.utils.DirectoryStateReceiver;
import org.citra.citra_emu.utils.EmulationMenuSettings;
import org.citra.citra_emu.utils.InsetsHelper;
import org.citra.citra_emu.utils.ThemeUtil;
@@ -48,8 +47,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
@Override
protected void onCreate(Bundle savedInstanceState) {
- ThemeUtil.applyTheme(this);
-
+ ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
@@ -109,7 +107,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
mPresenter.onStop(isFinishing());
// Update framebuffer layout when closing the settings
- NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
+ NativeLibrary.INSTANCE.notifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
getWindowManager().getDefaultDisplay().getRotation());
}
@@ -147,19 +145,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
return duration != 0 && transition != 0;
}
- @Override
- public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) {
- LocalBroadcastManager.getInstance(this).registerReceiver(
- receiver,
- filter);
- DirectoryInitialization.start(this);
- }
-
- @Override
- public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) {
- LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
- }
-
@Override
public void showLoading() {
if (dialog == null) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
index b4f7c22d1..84a7d9d64 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
@@ -11,7 +11,6 @@ import org.citra.citra_emu.features.settings.model.Settings;
import org.citra.citra_emu.features.settings.utils.SettingsFile;
import org.citra.citra_emu.utils.DirectoryInitialization;
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
-import org.citra.citra_emu.utils.DirectoryStateReceiver;
import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.ThemeUtil;
@@ -24,8 +23,6 @@ public final class SettingsActivityPresenter {
private boolean mShouldSave;
- private DirectoryStateReceiver directoryStateReceiver;
-
private String menuTag;
private String gameId;
@@ -64,30 +61,7 @@ public final class SettingsActivityPresenter {
if (configFile == null || !configFile.exists()) {
Log.error("Citra config file could not be found!");
}
- if (DirectoryInitialization.areCitraDirectoriesReady()) {
- loadSettingsUI();
- } else {
- mView.showLoading();
- IntentFilter statusIntentFilter = new IntentFilter(
- DirectoryInitialization.BROADCAST_ACTION);
-
- directoryStateReceiver =
- new DirectoryStateReceiver(directoryInitializationState ->
- {
- if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
- mView.hideLoading();
- loadSettingsUI();
- } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
- mView.showPermissionNeededHint();
- mView.hideLoading();
- } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
- mView.showExternalStorageNotMountedHint();
- mView.hideLoading();
- }
- });
-
- mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter);
- }
+ loadSettingsUI();
}
public void setSettings(Settings settings) {
@@ -99,17 +73,12 @@ public final class SettingsActivityPresenter {
}
public void onStop(boolean finishing) {
- if (directoryStateReceiver != null) {
- mView.stopListeningToDirectoryInitializationService(directoryStateReceiver);
- directoryStateReceiver = null;
- }
-
if (mSettings != null && finishing && mShouldSave) {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...");
mSettings.saveSettings(mView);
}
- NativeLibrary.ReloadSettings();
+ NativeLibrary.INSTANCE.reloadSettings();
}
public void onSettingChanged() {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java
index 0d26d48a7..bd2f5f5aa 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java
@@ -3,7 +3,6 @@ package org.citra.citra_emu.features.settings.ui;
import android.content.IntentFilter;
import org.citra.citra_emu.features.settings.model.Settings;
-import org.citra.citra_emu.utils.DirectoryStateReceiver;
/**
* Abstraction for the Activity that manages SettingsFragments.
@@ -85,19 +84,4 @@ public interface SettingsActivityView {
* Show a hint to the user that the app needs the external storage to be mounted
*/
void showExternalStorageNotMountedHint();
-
- /**
- * Start the DirectoryInitialization and listen for the result.
- *
- * @param receiver the broadcast receiver for the DirectoryInitialization
- * @param filter the Intent broadcasts to be received.
- */
- void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter);
-
- /**
- * Stop listening to the DirectoryInitialization.
- *
- * @param receiver The broadcast receiver to unregister.
- */
- void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver);
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java
index 59c37394e..b03ec11b4 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java
@@ -24,7 +24,6 @@ import org.citra.citra_emu.features.settings.model.StringSetting;
import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
-import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SliderSetting;
@@ -34,12 +33,10 @@ import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHo
import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder;
-import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder;
-import org.citra.citra_emu.ui.main.MainActivity;
import org.citra.citra_emu.utils.Log;
import java.util.ArrayList;
@@ -97,10 +94,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter onSingleChoiceClick(item));
- }
-
- public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) {
- mClickedPosition = position;
-
- if (!item.isPremium() || MainActivity.isPremiumActive()) {
- // Setting is either not Premium, or the user has Premium
- onSingleChoiceClick(item);
- return;
- }
-
- // User needs Premium, invoke the billing flow
- MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item));
+ onSingleChoiceClick(item);
}
public void onStringSingleChoiceClick(StringSingleChoiceSetting item) {
@@ -205,15 +166,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter onStringSingleChoiceClick(item));
+ onStringSingleChoiceClick(item);
}
DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog();
@@ -351,10 +304,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter 0) {
- int[] valuesArray = mContext.getResources().getIntArray(valuesId);
- return valuesArray[which];
- } else {
- return which;
- }
- }
-
private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) {
int value = item.getSelectedValue();
int valuesId = item.getValuesId();
@@ -447,25 +385,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter 0) {
- int[] valuesArray = mContext.getResources().getIntArray(valuesId);
- for (int index = 0; index < valuesArray.length; index++) {
- int current = valuesArray[index];
- if (current == value) {
- return index;
- }
- }
- } else {
- return value;
- }
-
- return -1;
- }
-
@Override
public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) {
mSliderProgress = (int) value;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
index 9f73e1ff2..3e53cf465 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
@@ -17,8 +17,6 @@ import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
import org.citra.citra_emu.features.settings.model.view.HeaderSetting;
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
-import org.citra.citra_emu.features.settings.model.view.PremiumHeader;
-import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SliderSetting;
@@ -107,9 +105,6 @@ public final class SettingsFragmentPresenter {
case SettingsFile.FILE_NAME_CONFIG:
addConfigSettings(sl);
break;
- case Settings.SECTION_PREMIUM:
- addPremiumSettings(sl);
- break;
case Settings.SECTION_CORE:
addGeneralSettings(sl);
break;
@@ -143,7 +138,6 @@ public final class SettingsFragmentPresenter {
private void addConfigSettings(ArrayList sl) {
mView.getActivity().setTitle(R.string.preferences_settings);
- sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM));
sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE));
sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM));
sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA));
@@ -153,25 +147,6 @@ public final class SettingsFragmentPresenter {
sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG));
}
- private void addPremiumSettings(ArrayList sl) {
- mView.getActivity().setTitle(R.string.preferences_premium);
-
- SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM);
- Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN);
-
- sl.add(new PremiumHeader());
-
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
- sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView));
- } else {
- // Pre-Android 10 does not support System Default
- sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView));
- }
-
- Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME);
- sl.add(new SingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, 0, R.array.textureFilterNames, R.array.textureFilterValues, 0, textureFilterName));
- }
-
private void addGeneralSettings(ArrayList sl) {
mView.getActivity().setTitle(R.string.preferences_general);
@@ -367,6 +342,7 @@ public final class SettingsFragmentPresenter {
Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D);
Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D);
Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE);
+ Setting textureFilterName = rendererSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME);
SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT);
Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE);
Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT);
@@ -385,6 +361,7 @@ public final class SettingsFragmentPresenter {
sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode));
sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul));
sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_RENDERER, R.string.texture_filter_name, 0, R.array.textureFilterNames, R.array.textureFilterValues, 0, textureFilterName));
sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0));
sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode));
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java
deleted file mode 100644
index be0853ff0..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.citra.citra_emu.features.settings.ui.viewholder;
-
-import android.view.View;
-import android.widget.TextView;
-
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.model.view.SettingsItem;
-import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
-import org.citra.citra_emu.features.settings.ui.SettingsFragmentView;
-import org.citra.citra_emu.ui.main.MainActivity;
-
-public final class PremiumViewHolder extends SettingViewHolder {
- private TextView mHeaderName;
- private TextView mTextDescription;
- private SettingsFragmentView mView;
-
- public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) {
- super(itemView, adapter);
- mView = view;
- itemView.setOnClickListener(this);
- }
-
- @Override
- protected void findViews(View root) {
- mHeaderName = root.findViewById(R.id.text_setting_name);
- mTextDescription = root.findViewById(R.id.text_setting_description);
- }
-
- @Override
- public void bind(SettingsItem item) {
- updateText();
- }
-
- @Override
- public void onClick(View clicked) {
- if (MainActivity.isPremiumActive()) {
- return;
- }
-
- // Invoke billing flow if Premium is not already active, then refresh the UI to indicate
- // the purchase has completed.
- MainActivity.invokePremiumBilling(() -> updateText());
- }
-
- /**
- * Update the text shown to the user, based on whether Premium is active
- */
- private void updateText() {
- if (MainActivity.isPremiumActive()) {
- mHeaderName.setText(R.string.premium_settings_welcome);
- mTextDescription.setText(R.string.premium_settings_welcome_description);
- } else {
- mHeaderName.setText(R.string.premium_settings_upsell);
- mTextDescription.setText(R.string.premium_settings_upsell_description);
- }
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java
index a175af9f8..f735b7752 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java
@@ -5,7 +5,6 @@ import android.view.View;
import android.widget.TextView;
import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
@@ -46,17 +45,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
mTextSettingDescription.setText(choices[i]);
}
}
- } else if (item instanceof PremiumSingleChoiceSetting) {
- PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item;
- int selected = setting.getSelectedValue();
- Resources resMgr = mTextSettingDescription.getContext().getResources();
- String[] choices = resMgr.getStringArray(setting.getChoicesId());
- int[] values = resMgr.getIntArray(setting.getValuesId());
- for (int i = 0; i < values.length; ++i) {
- if (values[i] == selected) {
- mTextSettingDescription.setText(choices[i]);
- }
- }
} else {
mTextSettingDescription.setVisibility(View.GONE);
}
@@ -67,8 +55,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
int position = getAdapterPosition();
if (mItem instanceof SingleChoiceSetting) {
getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position);
- } else if (mItem instanceof PremiumSingleChoiceSetting) {
- getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position);
} else if (mItem instanceof StringSingleChoiceSetting) {
getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position);
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
index fec8f3282..4590100cd 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
@@ -42,7 +42,6 @@ public final class SettingsFile {
public static final String KEY_DESIGN = "design";
- public static final String KEY_PREMIUM = "premium";
public static final String KEY_GRAPHICS_API = "graphics_api";
public static final String KEY_SPIRV_SHADER_GEN = "spirv_shader_gen";
@@ -160,7 +159,7 @@ public final class SettingsFile {
BufferedReader reader = null;
try {
- Context context = CitraApplication.getAppContext();
+ Context context = CitraApplication.Companion.getAppContext();
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
reader = new BufferedReader(new InputStreamReader(inputStream));
@@ -226,7 +225,7 @@ public final class SettingsFile {
DocumentFile ini = getSettingsFile(fileName);
try {
- Context context = CitraApplication.getAppContext();
+ Context context = CitraApplication.Companion.getAppContext();
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
Wini writer = new Wini(inputStream);
@@ -242,24 +241,7 @@ public final class SettingsFile {
outputStream.close();
} catch (IOException e) {
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage());
- view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
- }
- }
-
-
- public static void saveCustomGameSettings(final String gameId, final HashMap sections) {
- Set sortedSections = new TreeSet<>(sections.keySet());
-
- for (String sectionKey : sortedSections) {
- SettingSection section = sections.get(sectionKey);
-
- HashMap settings = section.getSettings();
- Set sortedKeySet = new TreeSet<>(settings.keySet());
-
- for (String settingKey : sortedKeySet) {
- Setting setting = settings.get(settingKey);
- NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString());
- }
+ view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
}
}
@@ -280,13 +262,13 @@ public final class SettingsFile {
}
public static DocumentFile getSettingsFile(String fileName) {
- DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
+ DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory()));
DocumentFile configDirectory = root.findFile("config");
return configDirectory.findFile(fileName + ".ini");
}
private static DocumentFile getCustomGameSettingsFile(String gameId) {
- DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
+ DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory()));
DocumentFile configDirectory = root.findFile("GameSettings");
return configDirectory.findFile(gameId + ".ini");
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt
new file mode 100644
index 000000000..3943aa23d
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt
@@ -0,0 +1,123 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+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 com.google.android.material.transition.MaterialSharedAxis
+import org.citra.citra_emu.BuildConfig
+import org.citra.citra_emu.R
+import org.citra.citra_emu.databinding.FragmentAboutBinding
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class AboutFragment : Fragment() {
+ private var _binding: FragmentAboutBinding? = 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 = FragmentAboutBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarAbout.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ binding.buttonContributors.setOnClickListener {
+ openLink(
+ getString(R.string.contributors_link)
+ )
+ }
+ binding.buttonLicenses.setOnClickListener {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment)
+ }
+
+ binding.textBuildHash.text = BuildConfig.VERSION_NAME
+ binding.buttonBuildHash.setOnClickListener {
+ val clipBoard =
+ requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH)
+ clipBoard.setPrimaryClip(clip)
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Toast.makeText(
+ requireContext(),
+ R.string.copied_to_clipboard,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
+ binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
+ binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
+
+ setInsets()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ 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.toolbarAbout.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.toolbarAbout.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.scrollAbout.layoutParams = mlpScrollAbout
+
+ binding.contentAbout.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt
new file mode 100644
index 000000000..aa2a0716f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt
@@ -0,0 +1,92 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+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.citra.citra_emu.R
+import org.citra.citra_emu.databinding.DialogCitraDirectoryBinding
+import org.citra.citra_emu.ui.main.MainActivity
+import org.citra.citra_emu.utils.PermissionsHandler
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class CitraDirectoryDialogFragment : DialogFragment() {
+ private lateinit var binding: DialogCitraDirectoryBinding
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ fun interface Listener {
+ fun onPressPositiveButton(moveData: Boolean, path: Uri)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ binding = DialogCitraDirectoryBinding.inflate(layoutInflater)
+
+ val path = Uri.parse(requireArguments().getString(PATH))
+
+ binding.checkBox.isChecked = savedInstanceState?.getBoolean(MOVE_DATE_ENABLE) ?: false
+ val oldPath = PermissionsHandler.citraDirectory
+ if (!PermissionsHandler.hasWriteAccess(requireActivity()) ||
+ oldPath.toString() == path.toString()
+ ) {
+ binding.checkBox.visibility = View.GONE
+ }
+ binding.path.text = path.path
+ binding.path.isSelected = true
+
+ isCancelable = false
+ return MaterialAlertDialogBuilder(requireContext())
+ .setView(binding.root)
+ .setTitle(R.string.select_citra_user_folder)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
+ homeViewModel.directoryListener?.onPressPositiveButton(
+ if (binding.checkBox.visibility != View.GONE) {
+ binding.checkBox.isChecked
+ } else {
+ false
+ },
+ path
+ )
+ }
+ .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int ->
+ if (!PermissionsHandler.hasWriteAccess(requireContext())) {
+ (requireActivity() as MainActivity).openCitraDirectory.launch(null)
+ }
+ }
+ .show()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(MOVE_DATE_ENABLE, binding.checkBox.isChecked)
+ }
+
+ companion object {
+ const val TAG = "citra_directory_dialog_fragment"
+ private const val MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE"
+ private const val PATH = "path"
+
+ fun newInstance(
+ activity: FragmentActivity,
+ path: String,
+ listener: Listener
+ ): CitraDirectoryDialogFragment {
+ val dialog = CitraDirectoryDialogFragment()
+ ViewModelProvider(activity)[HomeViewModel::class.java].directoryListener = listener
+ val args = Bundle()
+ args.putString(PATH, path)
+ dialog.arguments = args
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt
new file mode 100644
index 000000000..7fd92f979
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt
@@ -0,0 +1,153 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.databinding.DialogCopyDirBinding
+import org.citra.citra_emu.model.SetupCallback
+import org.citra.citra_emu.utils.CitraDirectoryHelper
+import org.citra.citra_emu.utils.FileUtil
+import org.citra.citra_emu.utils.PermissionsHandler
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class CopyDirProgressDialog : DialogFragment() {
+ private var _binding: DialogCopyDirBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ _binding = DialogCopyDirBinding.inflate(layoutInflater)
+
+ isCancelable = false
+ return MaterialAlertDialogBuilder(requireContext())
+ .setView(binding.root)
+ .setTitle(R.string.moving_data)
+ .create()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.messageText.collectLatest { binding.messageText.text = it }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.dirProgress.collectLatest {
+ binding.progressBar.max = homeViewModel.maxDirProgress.value
+ binding.progressBar.progress = it
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.copyComplete.collect {
+ if (it) {
+ homeViewModel.setUserDir(
+ requireActivity(),
+ PermissionsHandler.citraDirectory.path!!
+ )
+ homeViewModel.copyInProgress = false
+ homeViewModel.setPickingUserDir(false)
+ Toast.makeText(
+ requireContext(),
+ R.string.copy_complete,
+ Toast.LENGTH_SHORT
+ ).show()
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ _binding = null
+ }
+
+ companion object {
+ const val TAG = "CopyDirProgressDialog"
+
+ fun newInstance(
+ activity: FragmentActivity,
+ previous: Uri,
+ path: Uri,
+ callback: SetupCallback? = null
+ ): CopyDirProgressDialog? {
+ val viewModel = ViewModelProvider(activity)[HomeViewModel::class.java]
+ if (viewModel.copyInProgress) {
+ return null
+ }
+ viewModel.clearCopyInfo()
+ viewModel.copyInProgress = true
+
+ activity.lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ FileUtil.copyDir(
+ previous.toString(),
+ path.toString(),
+ object : FileUtil.CopyDirListener {
+ override fun onSearchProgress(directoryName: String) {
+ viewModel.onUpdateSearchProgress(
+ CitraApplication.appContext.resources,
+ directoryName
+ )
+ }
+
+ override fun onCopyProgress(filename: String, progress: Int, max: Int) {
+ viewModel.onUpdateCopyProgress(
+ CitraApplication.appContext.resources,
+ filename,
+ progress,
+ max
+ )
+ }
+
+ override fun onComplete() {
+ CitraDirectoryHelper.initializeCitraDirectory(path)
+ callback?.onStepCompleted()
+ viewModel.setCopyComplete(true)
+ }
+ })
+ }
+ }
+ return CopyDirProgressDialog()
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt
new file mode 100644
index 000000000..3f5abfd14
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt
@@ -0,0 +1,152 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.NativeLibrary.InstallStatus
+import org.citra.citra_emu.R
+import org.citra.citra_emu.databinding.DialogProgressBarBinding
+import org.citra.citra_emu.viewmodel.GamesViewModel
+import org.citra.citra_emu.viewmodel.SystemFilesViewModel
+
+class DownloadSystemFilesDialogFragment : DialogFragment() {
+ private var _binding: DialogProgressBarBinding? = null
+ private val binding get() = _binding!!
+
+ private val downloadViewModel: SystemFilesViewModel by activityViewModels()
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+
+ private lateinit var titles: LongArray
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ _binding = DialogProgressBarBinding.inflate(layoutInflater)
+
+ titles = requireArguments().getLongArray(TITLES)!!
+
+ binding.progressText.visibility = View.GONE
+
+ binding.progressBar.min = 0
+ binding.progressBar.max = titles.size
+ if (downloadViewModel.isDownloading.value != true) {
+ binding.progressBar.progress = 0
+ }
+
+ isCancelable = false
+ return MaterialAlertDialogBuilder(requireContext())
+ .setView(binding.root)
+ .setTitle(R.string.downloading_files)
+ .setMessage(R.string.downloading_files_description)
+ .setNegativeButton(android.R.string.cancel, null)
+ .create()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ downloadViewModel.progress.collectLatest { binding.progressBar.progress = it }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ downloadViewModel.result.collect {
+ when (it) {
+ InstallStatus.Success -> {
+ downloadViewModel.clear()
+ dismiss()
+ MessageDialogFragment.newInstance(R.string.download_success, 0)
+ .show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG)
+ gamesViewModel.setShouldSwapData(true)
+ }
+
+ InstallStatus.ErrorFailedToOpenFile,
+ InstallStatus.ErrorEncrypted,
+ InstallStatus.ErrorFileNotFound,
+ InstallStatus.ErrorInvalid,
+ InstallStatus.ErrorAborted -> {
+ downloadViewModel.clear()
+ dismiss()
+ MessageDialogFragment.newInstance(
+ R.string.download_failed,
+ R.string.download_failed_description
+ ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG)
+ gamesViewModel.setShouldSwapData(true)
+ }
+
+ InstallStatus.Cancelled -> {
+ downloadViewModel.clear()
+ dismiss()
+ MessageDialogFragment.newInstance(
+ R.string.download_cancelled,
+ R.string.download_cancelled_description
+ ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG)
+ }
+
+ // Do nothing on null
+ else -> {}
+ }
+ }
+ }
+ }
+ }
+
+ // Consider using WorkManager here. While the home menu can only really amount to
+ // about 150MBs, this could be a problem on inconsistent networks
+ downloadViewModel.download(titles)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ val alertDialog = dialog as AlertDialog
+ val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE)
+ negativeButton.setOnClickListener {
+ downloadViewModel.cancel()
+ dialog?.setTitle(R.string.cancelling)
+ binding.progressBar.isIndeterminate = true
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ const val TAG = "DownloadSystemFilesDialogFragment"
+
+ const val TITLES = "Titles"
+
+ fun newInstance(titles: LongArray): DownloadSystemFilesDialogFragment {
+ val dialog = DownloadSystemFilesDialogFragment()
+ val args = Bundle()
+ args.putLongArray(TITLES, titles)
+ dialog.arguments = args
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt
new file mode 100644
index 000000000..016ba34ae
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt
@@ -0,0 +1,182 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.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.documentfile.provider.DocumentFile
+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.citra.citra_emu.R
+import org.citra.citra_emu.adapters.DriverAdapter
+import org.citra.citra_emu.databinding.FragmentDriverManagerBinding
+import org.citra.citra_emu.utils.FileUtil.asDocumentFile
+import org.citra.citra_emu.utils.FileUtil.inputStream
+import org.citra.citra_emu.utils.GpuDriverHelper
+import org.citra.citra_emu.viewmodel.HomeViewModel
+import org.citra.citra_emu.viewmodel.DriverViewModel
+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.game_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
+ ) {
+ // Ignore file exceptions when a user selects an invalid zip
+ val driverFile: DocumentFile
+ try {
+ driverFile = GpuDriverHelper.copyDriverToExternalStorage(result)
+ ?: throw IOException("Driver failed validation!")
+ } catch (_: IOException) {
+ return@newInstance getString(R.string.select_gpu_driver_error)
+ }
+
+ val driverData = GpuDriverHelper.getMetadataFromZip(driverFile.inputStream())
+ val driverInList =
+ driverViewModel.driverList.value.firstOrNull { it.second == driverData }
+ if (driverInList != null) {
+ driverFile.delete()
+ return@newInstance getString(R.string.driver_already_installed)
+ } else {
+ driverViewModel.addDriver(Pair(driverFile.uri, driverData))
+ driverViewModel.setNewDriverInstalled(true)
+ }
+ return@newInstance Any()
+ }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt
new file mode 100644
index 000000000..8a6ff5c57
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt
@@ -0,0 +1,76 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.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.citra.citra_emu.R
+import org.citra.citra_emu.databinding.DialogProgressBarBinding
+import org.citra.citra_emu.viewmodel.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/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java
index 445faa047..834bd3317 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java
@@ -27,7 +27,6 @@ import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.overlay.InputOverlay;
import org.citra.citra_emu.utils.DirectoryInitialization;
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
-import org.citra.citra_emu.utils.DirectoryStateReceiver;
import org.citra.citra_emu.utils.EmulationMenuSettings;
import org.citra.citra_emu.utils.Log;
@@ -42,8 +41,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
private EmulationState mEmulationState;
- private DirectoryStateReceiver directoryStateReceiver;
-
private EmulationActivity activity;
private TextView mPerfStats;
@@ -65,7 +62,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
if (context instanceof EmulationActivity) {
activity = (EmulationActivity) context;
- NativeLibrary.setEmulationActivity((EmulationActivity) context);
+ NativeLibrary.INSTANCE.setEmulationActivity((EmulationActivity) context);
} else {
throw new IllegalStateException("EmulationFragment must have EmulationActivity parent");
}
@@ -116,20 +113,11 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
public void onResume() {
super.onResume();
Choreographer.getInstance().postFrameCallback(this);
- if (DirectoryInitialization.areCitraDirectoriesReady()) {
- mEmulationState.run(activity.isActivityRecreated());
- } else {
- setupCitraDirectoriesThenStartEmulation();
- }
+ mEmulationState.run(activity.isActivityRecreated());
}
@Override
public void onPause() {
- if (directoryStateReceiver != null) {
- LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver);
- directoryStateReceiver = null;
- }
-
if (mEmulationState.isRunning()) {
mEmulationState.pause();
}
@@ -140,39 +128,10 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
@Override
public void onDetach() {
- NativeLibrary.clearEmulationActivity();
+ NativeLibrary.INSTANCE.clearEmulationActivity();
super.onDetach();
}
- private void setupCitraDirectoriesThenStartEmulation() {
- IntentFilter statusIntentFilter = new IntentFilter(
- DirectoryInitialization.BROADCAST_ACTION);
-
- directoryStateReceiver =
- new DirectoryStateReceiver(directoryInitializationState ->
- {
- if (directoryInitializationState ==
- DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
- mEmulationState.run(activity.isActivityRecreated());
- } else if (directoryInitializationState ==
- DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
- Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT)
- .show();
- } else if (directoryInitializationState ==
- DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
- Toast.makeText(getContext(), R.string.external_storage_not_mounted,
- Toast.LENGTH_SHORT)
- .show();
- }
- });
-
- // Registers the DirectoryStateReceiver and its intent filters
- LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
- directoryStateReceiver,
- statusIntentFilter);
- DirectoryInitialization.start(getActivity());
- }
-
public void refreshInputOverlay() {
mInputOverlay.refreshControls();
}
@@ -195,7 +154,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
perfStatsUpdater = () ->
{
- final double[] perfStats = NativeLibrary.GetPerfStats();
+ final double[] perfStats = NativeLibrary.INSTANCE.getPerfStats();
if (perfStats[FPS] > 0) {
mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5),
(int) (perfStats[SPEED] * 100.0 + 0.5)));
@@ -235,7 +194,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
@Override
public void doFrame(long frameTimeNanos) {
Choreographer.getInstance().postFrameCallback(this);
- NativeLibrary.DoFrame();
+ NativeLibrary.INSTANCE.doFrame();
}
public void stopEmulation() {
@@ -286,7 +245,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
if (state != State.STOPPED) {
Log.debug("[EmulationFragment] Stopping emulation.");
state = State.STOPPED;
- NativeLibrary.StopEmulation();
+ NativeLibrary.INSTANCE.stopEmulation();
} else {
Log.warning("[EmulationFragment] Stop called while already stopped.");
}
@@ -300,8 +259,8 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
Log.debug("[EmulationFragment] Pausing emulation.");
// Release the surface before pausing, since emulation has to be running for that.
- NativeLibrary.SurfaceDestroyed();
- NativeLibrary.PauseEmulation();
+ NativeLibrary.INSTANCE.surfaceDestroyed();
+ NativeLibrary.INSTANCE.pauseEmulation();
} else {
Log.warning("[EmulationFragment] Pause called while already paused.");
}
@@ -309,7 +268,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
public synchronized void run(boolean isActivityRecreated) {
if (isActivityRecreated) {
- if (NativeLibrary.IsRunning()) {
+ if (NativeLibrary.INSTANCE.isRunning()) {
state = State.PAUSED;
}
} else {
@@ -340,7 +299,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
Log.debug("[EmulationFragment] Surface destroyed.");
if (state == State.RUNNING) {
- NativeLibrary.SurfaceDestroyed();
+ NativeLibrary.INSTANCE.surfaceDestroyed();
state = State.PAUSED;
} else if (state == State.PAUSED) {
Log.warning("[EmulationFragment] Surface cleared while emulation paused.");
@@ -353,18 +312,18 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
private void runWithValidSurface() {
mRunWhenSurfaceIsValid = false;
if (state == State.STOPPED) {
- NativeLibrary.SurfaceChanged(mSurface);
+ NativeLibrary.INSTANCE.surfaceChanged(mSurface);
Thread mEmulationThread = new Thread(() ->
{
Log.debug("[EmulationFragment] Starting emulation thread.");
- NativeLibrary.Run(mGamePath);
+ NativeLibrary.INSTANCE.run(mGamePath);
}, "NativeEmulation");
mEmulationThread.start();
} else if (state == State.PAUSED) {
Log.debug("[EmulationFragment] Resuming emulation.");
- NativeLibrary.SurfaceChanged(mSurface);
- NativeLibrary.UnPauseEmulation();
+ NativeLibrary.INSTANCE.surfaceChanged(mSurface);
+ NativeLibrary.INSTANCE.unPauseEmulation();
} else {
Log.debug("[EmulationFragment] Bug, run called while already running.");
}
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
new file mode 100644
index 000000000..4c93bef97
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt
@@ -0,0 +1,202 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.transition.MaterialFadeThrough
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.CitraApplication
+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.GamesViewModel
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class GamesFragment : Fragment() {
+ private var _binding: FragmentGamesBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGamesBinding.inflate(inflater)
+ return binding.root
+ }
+
+ // This is using the correct scope, lint is just acting up
+ @SuppressLint("UnsafeRepeatOnLifecycleDetector")
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+
+ binding.gridGames.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ resources.getInteger(R.integer.game_grid_columns)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.swipeRefresh.apply {
+ // Add swipe down to refresh gesture
+ setOnRefreshListener {
+ gamesViewModel.reloadGames(false)
+ }
+
+ // Set theme color to the refresh animation's background
+ setProgressBackgroundColorSchemeColor(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorPrimary
+ )
+ )
+ setColorSchemeColors(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorOnPrimary
+ )
+ )
+
+ // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
+ post {
+ if (_binding == null) {
+ return@post
+ }
+ binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value
+ }
+ }
+
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.isReloading.collect { isReloading ->
+ binding.swipeRefresh.isRefreshing = isReloading
+ if (gamesViewModel.games.value.isEmpty() && !isReloading) {
+ binding.noticeText.visibility = View.VISIBLE
+ } else {
+ binding.noticeText.visibility = View.INVISIBLE
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.games.collectLatest { setAdapter(it) }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.shouldSwapData.collect {
+ if (it) {
+ setAdapter(gamesViewModel.games.value)
+ gamesViewModel.setShouldSwapData(false)
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.shouldScrollToTop.collect {
+ if (it) {
+ scrollToTop()
+ gamesViewModel.setShouldScrollToTop(false)
+ }
+ }
+ }
+ }
+ }
+
+ setInsets()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun setAdapter(games: List) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ if (preferences.getBoolean(Settings.PREF_SHOW_HOME_APPS, false)) {
+ (binding.gridGames.adapter as GameAdapter).submitList(games)
+ } else {
+ val filteredList = games.filter { !it.isSystemTitle }
+ (binding.gridGames.adapter as GameAdapter).submitList(filteredList)
+ }
+ }
+
+ private fun scrollToTop() {
+ if (_binding != null) {
+ binding.gridGames.smoothScrollToPosition(0)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ binding.gridGames.updatePadding(
+ top = barInsets.top + extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+
+ binding.swipeRefresh.setProgressViewEndTarget(
+ false,
+ barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
+ )
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+ val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
+ mlpSwipe.rightMargin = rightInsets
+ } else {
+ mlpSwipe.leftMargin = leftInsets
+ mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
+ }
+ binding.swipeRefresh.layoutParams = mlpSwipe
+
+ binding.noticeText.updatePadding(bottom = spacingNavigation)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt
new file mode 100644
index 000000000..05379d8d6
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt
@@ -0,0 +1,252 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.documentfile.provider.DocumentFile
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.findNavController
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.adapters.HomeSettingAdapter
+import org.citra.citra_emu.databinding.FragmentHomeSettingsBinding
+import org.citra.citra_emu.features.settings.ui.SettingsActivity
+import org.citra.citra_emu.features.settings.utils.SettingsFile
+import org.citra.citra_emu.model.HomeSetting
+import org.citra.citra_emu.ui.main.MainActivity
+import org.citra.citra_emu.utils.GameHelper
+import org.citra.citra_emu.utils.PermissionsHandler
+import org.citra.citra_emu.viewmodel.HomeViewModel
+import org.citra.citra_emu.utils.GpuDriverHelper
+import org.citra.citra_emu.utils.Log
+import org.citra.citra_emu.viewmodel.DriverViewModel
+
+class HomeSettingsFragment : Fragment() {
+ private var _binding: FragmentHomeSettingsBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var mainActivity: MainActivity
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+ private val driverViewModel: DriverViewModel by activityViewModels()
+
+ private val preferences get() =
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentHomeSettingsBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ val optionsList = listOf(
+ HomeSetting(
+ R.string.grid_menu_core_settings,
+ R.string.settings_description,
+ R.drawable.ic_settings,
+ { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }
+ ),
+ HomeSetting(
+ R.string.system_files,
+ R.string.system_files_description,
+ R.drawable.ic_system_update,
+ {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_systemFilesFragment)
+ }
+ ),
+ HomeSetting(
+ R.string.install_game_content,
+ R.string.install_game_content_description,
+ R.drawable.ic_install,
+ { mainActivity.ciaFileInstaller.launch(true) }
+ ),
+ HomeSetting(
+ R.string.share_log,
+ R.string.share_log_description,
+ R.drawable.ic_share,
+ { shareLog() }
+ ),
+ HomeSetting(
+ R.string.gpu_driver_manager,
+ R.string.install_gpu_driver_description,
+ R.drawable.ic_install_driver,
+ {
+ 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,
+ driverViewModel.selectedDriverMetadata
+ ),
+ HomeSetting(
+ R.string.select_citra_user_folder,
+ R.string.select_citra_user_folder_home_description,
+ R.drawable.ic_home,
+ { mainActivity.openCitraDirectory.launch(null) },
+ details = homeViewModel.userDir
+ ),
+ HomeSetting(
+ R.string.select_games_folder,
+ R.string.select_games_folder_description,
+ R.drawable.ic_add,
+ { getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+ details = homeViewModel.gamesDir
+ ),
+ HomeSetting(
+ R.string.about,
+ R.string.about_description,
+ R.drawable.ic_info_outline,
+ {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment)
+ }
+ )
+ )
+
+ binding.homeSettingsList.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ resources.getInteger(R.integer.game_grid_columns)
+ )
+ adapter = HomeSettingAdapter(
+ requireActivity() as AppCompatActivity,
+ viewLifecycleOwner,
+ optionsList
+ )
+ }
+
+ setInsets()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ exitTransition = null
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private val getGamesDirectory =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ requireContext().contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ preferences.edit()
+ .putString(GameHelper.KEY_GAME_PATH, result.toString())
+ .apply()
+
+ Toast.makeText(
+ CitraApplication.appContext,
+ R.string.games_dir_selected,
+ Toast.LENGTH_LONG
+ ).show()
+
+ homeViewModel.setGamesDir(requireActivity(), result.path!!)
+ }
+
+ private fun shareLog() {
+ val logDirectory = DocumentFile.fromTreeUri(
+ requireContext(),
+ PermissionsHandler.citraDirectory
+ )?.findFile("log")
+ val currentLog = logDirectory?.findFile("citra_log.txt")
+ val oldLog = logDirectory?.findFile("citra_log.txt.old.txt")
+
+ val intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ type = "text/plain"
+ }
+ if (!Log.gameLaunched && oldLog?.exists() == true) {
+ intent.putExtra(Intent.EXTRA_STREAM, oldLog.uri)
+ startActivity(Intent.createChooser(intent, getText(R.string.share_log)))
+ } else if (currentLog?.exists() == true) {
+ intent.putExtra(Intent.EXTRA_STREAM, currentLog.uri)
+ startActivity(Intent.createChooser(intent, getText(R.string.share_log)))
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getText(R.string.share_log_not_found),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ binding.scrollViewSettings.updatePadding(
+ top = barInsets.top,
+ bottom = barInsets.bottom
+ )
+
+ val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams
+ mlpScrollSettings.leftMargin = leftInsets
+ mlpScrollSettings.rightMargin = rightInsets
+ binding.scrollViewSettings.layoutParams = mlpScrollSettings
+
+ binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation)
+
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail)
+ } else {
+ binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail)
+ }
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt
new file mode 100644
index 000000000..31b01e636
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -0,0 +1,137 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+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
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.R
+import org.citra.citra_emu.databinding.DialogProgressBarBinding
+import org.citra.citra_emu.viewmodel.TaskViewModel
+
+class IndeterminateProgressDialogFragment : DialogFragment() {
+ private val taskViewModel: TaskViewModel by activityViewModels()
+
+ private lateinit var binding: DialogProgressBarBinding
+
+ 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)
+
+ if (cancellable) {
+ dialog.setNegativeButton(android.R.string.cancel, null)
+ }
+
+ val alertDialog = dialog.create()
+ alertDialog.setCanceledOnTouchOutside(false)
+
+ if (!taskViewModel.isRunning.value) {
+ taskViewModel.runTask()
+ }
+ return alertDialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ 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)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 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: 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/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt
new file mode 100644
index 000000000..9cbea1b9b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt
@@ -0,0 +1,70 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.citra.citra_emu.databinding.DialogLicenseBinding
+import org.citra.citra_emu.model.License
+import org.citra.citra_emu.utils.SerializableHelper.parcelable
+
+class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() {
+ private var _binding: DialogLicenseBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = DialogLicenseBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ BottomSheetBehavior.from(view.parent as View).state =
+ BottomSheetBehavior.STATE_HALF_EXPANDED
+
+ val license = requireArguments().parcelable(LICENSE)!!
+
+ binding.apply {
+ textTitle.setText(license.titleId)
+ textLink.setText(license.linkId)
+ if (license.copyrightId != 0) {
+ textCopyright.setText(license.copyrightId)
+ } else {
+ textCopyright.visibility = View.GONE
+ }
+ if (license.licenseId != 0) {
+ textLicense.setText(license.licenseId)
+ } else {
+ textLicense.setText(license.licenseLinkId)
+ BottomSheetBehavior.from(view.parent as View).state =
+ BottomSheetBehavior.STATE_COLLAPSED
+ }
+ }
+ }
+
+ companion object {
+ const val TAG = "LicenseBottomSheetDialogFragment"
+
+ const val LICENSE = "License"
+
+ fun newInstance(
+ license: License
+ ): LicenseBottomSheetDialogFragment {
+ val dialog = LicenseBottomSheetDialogFragment()
+ val bundle = Bundle()
+ bundle.putParcelable(LICENSE, license)
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt
new file mode 100644
index 000000000..a8f767907
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt
@@ -0,0 +1,201 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+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.LinearLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.citra.citra_emu.R
+import org.citra.citra_emu.adapters.LicenseAdapter
+import org.citra.citra_emu.databinding.FragmentLicensesBinding
+import org.citra.citra_emu.model.License
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class LicensesFragment : Fragment() {
+ private var _binding: FragmentLicensesBinding? = 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)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentLicensesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarLicenses.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ val licenses = listOf(
+ License(
+ R.string.license_adreno_tools,
+ R.string.license_adreno_tools_description,
+ R.string.license_adreno_tools_link,
+ R.string.license_adreno_tools_copyright,
+ R.string.license_adreno_tools_text
+ ),
+ License(
+ R.string.license_cubeb,
+ R.string.license_cubeb_description,
+ R.string.license_cubeb_link,
+ R.string.license_cubeb_copyright,
+ R.string.license_cubeb_text
+ ),
+ License(
+ R.string.license_dynarmic,
+ R.string.license_dynarmic_description,
+ R.string.license_dynarmic_link,
+ R.string.license_dynarmic_copyright,
+ R.string.license_dynarmic_text
+ ),
+ License(
+ R.string.license_sirit,
+ R.string.license_sirit_description,
+ R.string.license_sirit_link,
+ R.string.license_sirit_copyright,
+ R.string.license_sirit_text
+ ),
+ License(
+ R.string.license_cryptopp,
+ R.string.license_cryptopp_description,
+ R.string.license_cryptopp_link,
+ R.string.license_cryptopp_copyright,
+ R.string.license_cryptopp_text
+ ),
+ License(
+ titleId = R.string.license_boost,
+ descriptionId = R.string.license_boost_description,
+ linkId = R.string.license_boost_link,
+ licenseId = R.string.license_boost_text
+ ),
+ License(
+ R.string.license_nihstro,
+ R.string.license_nihstro_description,
+ R.string.license_nihstro_link,
+ R.string.license_nihstro_copyright,
+ R.string.license_nihstro_text
+ ),
+ License(
+ R.string.license_httplib,
+ R.string.license_httplib_description,
+ R.string.license_httplib_link,
+ R.string.license_httplib_copyright,
+ R.string.license_mit
+ ),
+ License(
+ R.string.license_teakra,
+ R.string.license_teakra_description,
+ R.string.license_teakra_link,
+ R.string.license_teakra_copyright,
+ R.string.license_mit
+ ),
+ License(
+ R.string.license_enet,
+ R.string.license_enet_description,
+ R.string.license_enet_link,
+ R.string.license_enet_copyright,
+ R.string.license_mit
+ ),
+ License(
+ R.string.license_glad,
+ R.string.license_glad_description,
+ R.string.license_glad_link,
+ R.string.license_glad_copyright,
+ R.string.license_mit
+ ),
+ License(
+ titleId = R.string.license_glslang,
+ descriptionId = R.string.license_glslang_description,
+ linkId = R.string.license_glslang_link,
+ licenseLinkId = R.string.license_glslang_link_license
+ ),
+ License(
+ R.string.license_openal,
+ R.string.license_openal_description,
+ R.string.license_openal_link,
+ R.string.license_openal_copyright,
+ R.string.license_openal_text
+ ),
+ License(
+ R.string.license_sdl,
+ R.string.license_sdl_description,
+ R.string.license_sdl_link,
+ R.string.license_sdl_copyright,
+ R.string.license_sdl_text
+ ),
+ License(
+ R.string.license_vma,
+ R.string.license_vma_description,
+ R.string.license_vma_link,
+ R.string.license_vma_copyright,
+ R.string.license_mit
+ ),
+ License(
+ R.string.license_zstd,
+ R.string.license_zstd_description,
+ R.string.license_zstd_link,
+ R.string.license_zstd_copyright,
+ R.string.license_zstd_text
+ )
+ )
+
+ binding.listLicenses.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses)
+ }
+
+ 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.toolbarLicenses.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.toolbarLicenses.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.listLicenses.layoutParams = mlpScrollAbout
+
+ binding.listLicenses.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt
new file mode 100644
index 000000000..93113a1ff
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt
@@ -0,0 +1,86 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.citra.citra_emu.R
+
+class MessageDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val titleId = requireArguments().getInt(TITLE_ID)
+ val descriptionId = requireArguments().getInt(DESCRIPTION_ID)
+ val descriptionString = requireArguments().getString(DESCRIPTION_STRING) ?: ""
+ val helpLinkId = requireArguments().getInt(HELP_LINK)
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.close, null)
+ .setTitle(titleId)
+
+ if (descriptionString.isNotEmpty()) {
+ dialog.setMessage(descriptionString)
+ } else if (descriptionId != 0) {
+ dialog.setMessage(descriptionId)
+ }
+
+ if (helpLinkId != 0) {
+ dialog.setNeutralButton(R.string.learn_more) { _, _ ->
+ openLink(getString(helpLinkId))
+ }
+ }
+
+ return dialog.show()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ companion object {
+ const val TAG = "MessageDialogFragment"
+
+ private const val TITLE_ID = "Title"
+ private const val DESCRIPTION_ID = "Description"
+ private const val DESCRIPTION_STRING = "Description_string"
+ private const val HELP_LINK = "Link"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int = 0
+ ): MessageDialogFragment {
+ val dialog = MessageDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE_ID, titleId)
+ putInt(DESCRIPTION_ID, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+
+ fun newInstance(
+ titleId: Int,
+ description: String,
+ helpLinkId: Int = 0
+ ): MessageDialogFragment {
+ val dialog = MessageDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE_ID, titleId)
+ putString(DESCRIPTION_STRING, description)
+ putInt(HELP_LINK, helpLinkId)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
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
new file mode 100644
index 000000000..a38dd1471
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt
@@ -0,0 +1,260 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.GridLayoutManager
+import info.debatty.java.stringsimilarity.Jaccard
+import info.debatty.java.stringsimilarity.JaroWinkler
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+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.GamesViewModel
+import org.citra.citra_emu.viewmodel.HomeViewModel
+import java.time.temporal.ChronoField
+import java.util.Locale
+
+class SearchFragment : Fragment() {
+ private var _binding: FragmentSearchBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private lateinit var preferences: SharedPreferences
+
+ companion object {
+ private const val SEARCH_TEXT = "SearchText"
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSearchBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ // This is using the correct scope, lint is just acting up
+ @SuppressLint("UnsafeRepeatOnLifecycleDetector")
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+
+ preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ if (savedInstanceState != null) {
+ binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
+ }
+
+ binding.gridGamesSearch.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ resources.getInteger(R.integer.game_grid_columns)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
+
+ binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
+ if (text.toString().isNotEmpty()) {
+ binding.clearButton.visibility = View.VISIBLE
+ } else {
+ binding.clearButton.visibility = View.INVISIBLE
+ }
+ filterAndSearch()
+ }
+
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ gamesViewModel.searchFocused.collect {
+ if (it) {
+ focusSearch()
+ gamesViewModel.setSearchFocused(false)
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ gamesViewModel.games.collect { filterAndSearch() }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ gamesViewModel.searchedGames.collect {
+ (binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
+ if (it.isEmpty()) {
+ binding.noResultsView.visibility = View.VISIBLE
+ } else {
+ binding.noResultsView.visibility = View.GONE
+ }
+ }
+ }
+ }
+ }
+
+ binding.clearButton.setOnClickListener { binding.searchText.setText("") }
+
+ binding.searchBackground.setOnClickListener { focusSearch() }
+
+ setInsets()
+ filterAndSearch()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+ }
+
+ private inner class ScoredGame(val score: Double, val item: Game)
+
+ private fun filterAndSearch() {
+ if (binding.searchText.text.toString().isEmpty() &&
+ binding.chipGroup.checkedChipId == View.NO_ID
+ ) {
+ gamesViewModel.setSearchedGames(emptyList())
+ return
+ }
+
+ val baseList = gamesViewModel.games.value
+ val filteredList: List = when (binding.chipGroup.checkedChipId) {
+ R.id.chip_recently_played -> {
+ baseList.filter {
+ val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
+ lastPlayedTime > (System.currentTimeMillis() - ChronoField.MILLI_OF_DAY.range().maximum)
+ }
+ }
+
+ R.id.chip_recently_added -> {
+ baseList.filter {
+ val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
+ addedTime > (System.currentTimeMillis() - ChronoField.MILLI_OF_DAY.range().maximum)
+ }
+ }
+
+ R.id.chip_installed -> baseList.filter { it.isInstalled }
+
+ else -> baseList
+ }
+
+ if (binding.searchText.text.toString().isEmpty() &&
+ binding.chipGroup.checkedChipId != View.NO_ID
+ ) {
+ gamesViewModel.setSearchedGames(filteredList)
+ return
+ }
+
+ val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
+ val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
+ val sortedList: List = filteredList.mapNotNull { game ->
+ val title = game.title.lowercase(Locale.getDefault())
+ val score = searchAlgorithm.similarity(searchTerm, title)
+ if (score > 0.03) {
+ ScoredGame(score, game)
+ } else {
+ null
+ }
+ }.sortedByDescending { it.score }.map { it.item }
+ gamesViewModel.setSearchedGames(sortedList)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ if (_binding != null) {
+ outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
+ }
+ }
+
+ private fun focusSearch() {
+ if (_binding != null) {
+ binding.searchText.requestFocus()
+ val imm = requireActivity()
+ .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
+ imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+ val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
+
+ binding.constraintSearch.updatePadding(
+ left = barInsets.left + cutoutInsets.left,
+ top = barInsets.top,
+ right = barInsets.right + cutoutInsets.right
+ )
+
+ binding.gridGamesSearch.updatePadding(
+ top = extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+ binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
+
+ val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.frameSearch.updatePadding(left = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
+ binding.noResultsView.updatePadding(left = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing + spacingNavigationRail,
+ right = chipSpacing
+ )
+ mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
+ mlpDivider.rightMargin = chipSpacing
+ } else {
+ binding.frameSearch.updatePadding(right = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
+ binding.noResultsView.updatePadding(right = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing,
+ right = chipSpacing + spacingNavigationRail
+ )
+ mlpDivider.leftMargin = chipSpacing
+ mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
+ }
+ binding.divider.layoutParams = mlpDivider
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt
new file mode 100644
index 000000000..12fbbee1e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt
@@ -0,0 +1,42 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.citra.citra_emu.R
+import org.citra.citra_emu.ui.main.MainActivity
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class SelectUserDirectoryDialogFragment : DialogFragment() {
+ private lateinit var mainActivity: MainActivity
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ mainActivity = requireActivity() as MainActivity
+
+ isCancelable = false
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.select_citra_user_folder)
+ .setMessage(R.string.cannot_skip_directory_description)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
+ mainActivity.openCitraDirectory.launch(null)
+ }
+ .show()
+ }
+
+ companion object {
+ const val TAG = "SelectUserDirectoryDialogFragment"
+
+ fun newInstance(activity: FragmentActivity): SelectUserDirectoryDialogFragment {
+ ViewModelProvider(activity)[HomeViewModel::class.java].setPickingUserDir(true)
+ return SelectUserDirectoryDialogFragment()
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt
new file mode 100644
index 000000000..18d94b512
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt
@@ -0,0 +1,481 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.Manifest
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.preference.PreferenceManager
+import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.transition.MaterialFadeThrough
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.adapters.SetupAdapter
+import org.citra.citra_emu.databinding.FragmentSetupBinding
+import org.citra.citra_emu.features.settings.model.Settings
+import org.citra.citra_emu.model.SetupCallback
+import org.citra.citra_emu.model.SetupPage
+import org.citra.citra_emu.model.StepState
+import org.citra.citra_emu.ui.main.MainActivity
+import org.citra.citra_emu.utils.CitraDirectoryHelper
+import org.citra.citra_emu.utils.GameHelper
+import org.citra.citra_emu.utils.PermissionsHandler
+import org.citra.citra_emu.utils.ViewUtils
+import org.citra.citra_emu.viewmodel.GamesViewModel
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class SetupFragment : Fragment() {
+ private var _binding: FragmentSetupBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+
+ private lateinit var mainActivity: MainActivity
+
+ private lateinit var hasBeenWarned: BooleanArray
+
+ private lateinit var pages: MutableList
+
+ private val preferences: SharedPreferences
+ get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ companion object {
+ const val KEY_NEXT_VISIBILITY = "NextButtonVisibility"
+ const val KEY_BACK_VISIBILITY = "BackButtonVisibility"
+ const val KEY_HAS_BEEN_WARNED = "HasBeenWarned"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ exitTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSetupBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ homeViewModel.setNavigationVisibility(visible = false, animated = false)
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.viewPager2.currentItem > 0) {
+ pageBackward()
+ } else {
+ requireActivity().finish()
+ }
+ }
+ }
+ )
+
+ requireActivity().window.navigationBarColor =
+ ContextCompat.getColor(requireContext(), android.R.color.transparent)
+
+ pages = mutableListOf()
+ pages.apply {
+ add(
+ SetupPage(
+ R.drawable.ic_citra_full,
+ R.string.welcome,
+ R.string.welcome_description,
+ 0,
+ true,
+ R.string.get_started,
+ { pageForward() }
+ )
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(
+ SetupPage(
+ R.drawable.ic_notification,
+ R.string.notifications,
+ R.string.notifications_description,
+ 0,
+ false,
+ R.string.give_permission,
+ {
+ notificationCallback = it
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ },
+ false,
+ true,
+ {
+ if (NotificationManagerCompat.from(requireContext())
+ .areNotificationsEnabled()
+ ) {
+ StepState.STEP_COMPLETE
+ } else {
+ StepState.STEP_INCOMPLETE
+ }
+ },
+ R.string.notification_warning,
+ R.string.notification_warning_description,
+ 0
+ )
+ )
+ }
+
+ add(
+ SetupPage(
+ R.drawable.ic_microphone,
+ R.string.microphone_permission,
+ R.string.microphone_permission_description,
+ 0,
+ false,
+ R.string.give_permission,
+ {
+ microphoneCallback = it
+ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ },
+ false,
+ false,
+ {
+ if (
+ ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ StepState.STEP_COMPLETE
+ } else {
+ StepState.STEP_INCOMPLETE
+ }
+ }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_camera,
+ R.string.camera_permission,
+ R.string.camera_permission_description,
+ 0,
+ false,
+ R.string.give_permission,
+ {
+ cameraCallback = it
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ },
+ false,
+ false,
+ {
+ if (
+ ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.CAMERA
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ StepState.STEP_COMPLETE
+ } else {
+ StepState.STEP_INCOMPLETE
+ }
+ }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_home,
+ R.string.select_citra_user_folder,
+ R.string.select_citra_user_folder_description,
+ 0,
+ true,
+ R.string.select,
+ {
+ userDirCallback = it
+ openCitraDirectory.launch(null)
+ },
+ true,
+ true,
+ {
+ if (PermissionsHandler.hasWriteAccess(requireContext())) {
+ StepState.STEP_COMPLETE
+ } else {
+ StepState.STEP_INCOMPLETE
+ }
+ },
+ R.string.cannot_skip,
+ R.string.cannot_skip_directory_description,
+ R.string.cannot_skip_directory_help
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_controller,
+ R.string.games,
+ R.string.games_description,
+ R.drawable.ic_add,
+ true,
+ R.string.add_games,
+ {
+ gamesDirCallback = it
+ getGamesDirectory.launch(
+ Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data
+ )
+ },
+ false,
+ true,
+ {
+ if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) {
+ StepState.STEP_COMPLETE
+ } else {
+ StepState.STEP_INCOMPLETE
+ }
+ },
+ R.string.add_games_warning,
+ R.string.add_games_warning_description,
+ R.string.add_games_warning_help
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_check,
+ R.string.done,
+ R.string.done_description,
+ R.drawable.ic_arrow_forward,
+ false,
+ R.string.text_continue,
+ { finishSetup() }
+ )
+ )
+ }
+
+ binding.viewPager2.apply {
+ adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
+ offscreenPageLimit = 2
+ isUserInputEnabled = false
+ }
+
+ binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() {
+ var previousPosition: Int = 0
+
+ override fun onPageSelected(position: Int) {
+ super.onPageSelected(position)
+
+ if (position == 1 && previousPosition == 0) {
+ ViewUtils.showView(binding.buttonNext)
+ ViewUtils.showView(binding.buttonBack)
+ } else if (position == 0 && previousPosition == 1) {
+ ViewUtils.hideView(binding.buttonBack)
+ ViewUtils.hideView(binding.buttonNext)
+ } else if (position == pages.size - 1 && previousPosition == pages.size - 2) {
+ ViewUtils.hideView(binding.buttonNext)
+ } else if (position == pages.size - 2 && previousPosition == pages.size - 1) {
+ ViewUtils.showView(binding.buttonNext)
+ }
+
+ previousPosition = position
+ }
+ })
+
+ binding.buttonNext.setOnClickListener {
+ val index = binding.viewPager2.currentItem
+ val currentPage = pages[index]
+
+ // Checks if the user has completed the task on the current page
+ if (currentPage.hasWarning || currentPage.isUnskippable) {
+ val stepState = currentPage.stepCompleted.invoke()
+ if (stepState == StepState.STEP_COMPLETE ||
+ stepState == StepState.STEP_UNDEFINED
+ ) {
+ pageForward()
+ return@setOnClickListener
+ }
+
+ if (currentPage.isUnskippable) {
+ MessageDialogFragment.newInstance(
+ currentPage.warningTitleId,
+ currentPage.warningDescriptionId,
+ currentPage.warningHelpLinkId
+ ).show(childFragmentManager, MessageDialogFragment.TAG)
+ return@setOnClickListener
+ }
+
+ if (!hasBeenWarned[index]) {
+ SetupWarningDialogFragment.newInstance(
+ currentPage.warningTitleId,
+ currentPage.warningDescriptionId,
+ currentPage.warningHelpLinkId,
+ index
+ ).show(childFragmentManager, SetupWarningDialogFragment.TAG)
+ return@setOnClickListener
+ }
+ }
+ pageForward()
+ }
+ binding.buttonBack.setOnClickListener { pageBackward() }
+
+ if (savedInstanceState != null) {
+ val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY)
+ val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY)
+ hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!!
+
+ if (nextIsVisible) {
+ binding.buttonNext.visibility = View.VISIBLE
+ }
+ if (backIsVisible) {
+ binding.buttonBack.visibility = View.VISIBLE
+ }
+ } else {
+ hasBeenWarned = BooleanArray(pages.size)
+ }
+
+ setInsets()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
+ outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
+ outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private lateinit var notificationCallback: SetupCallback
+ private lateinit var microphoneCallback: SetupCallback
+ private lateinit var cameraCallback: SetupCallback
+
+ private val permissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) {
+ val page = pages[binding.viewPager2.currentItem]
+ when (page.titleId) {
+ R.string.notifications -> notificationCallback.onStepCompleted()
+ R.string.microphone_permission -> microphoneCallback.onStepCompleted()
+ R.string.camera_permission -> cameraCallback.onStepCompleted()
+ }
+ return@registerForActivityResult
+ }
+
+ Snackbar.make(binding.root, R.string.permission_denied, Snackbar.LENGTH_LONG)
+ .setAnchorView(binding.buttonNext)
+ .setAction(R.string.grid_menu_core_settings) {
+ val intent =
+ Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ val uri = Uri.fromParts("package", requireActivity().packageName, null)
+ intent.data = uri
+ startActivity(intent)
+ }
+ .show()
+ }
+
+ private lateinit var userDirCallback: SetupCallback
+
+ private val openCitraDirectory = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { result: Uri? ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ CitraDirectoryHelper(requireActivity()).showCitraDirectoryDialog(result, userDirCallback)
+ }
+
+ private lateinit var gamesDirCallback: SetupCallback
+
+ private val getGamesDirectory =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ requireActivity().contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ preferences.edit()
+ .putString(GameHelper.KEY_GAME_PATH, result.toString())
+ .apply()
+
+ homeViewModel.setGamesDir(requireActivity(), result.path!!)
+
+ gamesDirCallback.onStepCompleted()
+ }
+
+ private fun finishSetup() {
+ preferences.edit()
+ .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
+ .apply()
+ mainActivity.finishSetup(binding.root.findNavController())
+ }
+
+ fun pageForward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1
+ }
+
+ fun pageBackward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1
+ }
+
+ fun setPageWarned(page: Int) {
+ hasBeenWarned[page] = true
+ }
+
+ 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 leftPadding = barInsets.left + cutoutInsets.left
+ val topPadding = barInsets.top + cutoutInsets.top
+ val rightPadding = barInsets.right + cutoutInsets.right
+ val bottomPadding = barInsets.bottom + cutoutInsets.bottom
+
+ if (resources.getBoolean(R.bool.small_layout)) {
+ binding.viewPager2
+ .updatePadding(left = leftPadding, top = topPadding, right = rightPadding)
+ binding.constraintButtons
+ .updatePadding(left = leftPadding, right = rightPadding, bottom = bottomPadding)
+ } else {
+ binding.viewPager2.updatePadding(top = topPadding, bottom = bottomPadding)
+ binding.constraintButtons
+ .setPadding(
+ leftPadding + rightPadding,
+ topPadding,
+ rightPadding + leftPadding,
+ bottomPadding
+ )
+ }
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt
new file mode 100644
index 000000000..20a1e0baf
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt
@@ -0,0 +1,87 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.citra.citra_emu.R
+
+class SetupWarningDialogFragment : DialogFragment() {
+ private var titleId: Int = 0
+ private var descriptionId: Int = 0
+ private var helpLinkId: Int = 0
+ private var page: Int = 0
+
+ private lateinit var setupFragment: SetupFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ titleId = requireArguments().getInt(TITLE)
+ descriptionId = requireArguments().getInt(DESCRIPTION)
+ helpLinkId = requireArguments().getInt(HELP_LINK)
+ page = requireArguments().getInt(PAGE)
+
+ setupFragment = requireParentFragment() as SetupFragment
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
+ setupFragment.pageForward()
+ setupFragment.setPageWarned(page)
+ }
+ .setNegativeButton(R.string.warning_cancel, null)
+
+ if (titleId != 0) {
+ builder.setTitle(titleId)
+ } else {
+ builder.setTitle("")
+ }
+ if (descriptionId != 0) {
+ builder.setMessage(descriptionId)
+ }
+ if (helpLinkId != 0) {
+ builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
+ val helpLink = resources.getString(helpLinkId)
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
+ startActivity(intent)
+ }
+ }
+
+ return builder.show()
+ }
+
+ companion object {
+ const val TAG = "SetupWarningDialogFragment"
+
+ private const val TITLE = "Title"
+ private const val DESCRIPTION = "Description"
+ private const val HELP_LINK = "HelpLink"
+ private const val PAGE = "Page"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int,
+ page: Int
+ ): SetupWarningDialogFragment {
+ val dialog = SetupWarningDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE, titleId)
+ putInt(DESCRIPTION, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ putInt(PAGE, page)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt
new file mode 100644
index 000000000..3a9f8167c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt
@@ -0,0 +1,301 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.fragments
+
+import android.content.res.Resources
+import android.os.Bundle
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.findNavController
+import androidx.preference.PreferenceManager
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.android.material.transition.MaterialSharedAxis
+import kotlinx.coroutines.flow.collect
+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.activities.EmulationActivity
+import org.citra.citra_emu.databinding.FragmentSystemFilesBinding
+import org.citra.citra_emu.features.settings.model.Settings
+import org.citra.citra_emu.viewmodel.GamesViewModel
+import org.citra.citra_emu.viewmodel.HomeViewModel
+import org.citra.citra_emu.viewmodel.SystemFilesViewModel
+
+class SystemFilesFragment : Fragment() {
+ private var _binding: FragmentSystemFilesBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+ private val systemFilesViewModel: SystemFilesViewModel by activityViewModels()
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+
+ private lateinit var regionValues: IntArray
+
+ private val systemTypeDropdown = DropdownItem(R.array.systemFileTypeValues)
+ private val systemRegionDropdown = DropdownItem(R.array.systemFileRegionValues)
+
+ private val SYS_TYPE = "SysType"
+ private val REGION = "Region"
+ private val REGION_START = "RegionStart"
+
+ private val homeMenuMap: MutableMap = mutableMapOf()
+
+ private val WARNING_SHOWN = "SystemFilesWarningShown"
+
+ private class DropdownItem(val valuesId: Int) : AdapterView.OnItemClickListener {
+ var position = 0
+
+ fun getValue(resources: Resources): Int {
+ return resources.getIntArray(valuesId)[position]
+ }
+
+ override fun onItemClick(p0: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ this.position = position
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ NativeLibrary.loadSystemConfig()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSystemFilesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ if (!preferences.getBoolean(WARNING_SHOWN, false)) {
+ MessageDialogFragment.newInstance(
+ R.string.home_menu_warning,
+ R.string.home_menu_warning_description
+ ).show(childFragmentManager, MessageDialogFragment.TAG)
+ preferences.edit()
+ .putBoolean(WARNING_SHOWN, true)
+ .apply()
+ }
+
+ binding.toolbarSystemFiles.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ // TODO: Remove workaround for text filtering issue in material components when fixed
+ // https://github.com/material-components/material-components-android/issues/1464
+ binding.dropdownSystemType.isSaveEnabled = false
+ binding.dropdownSystemRegion.isSaveEnabled = false
+ binding.dropdownSystemRegionStart.isSaveEnabled = false
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ systemFilesViewModel.shouldRefresh.collect {
+ if (it) {
+ reloadUi()
+ systemFilesViewModel.setShouldRefresh(false)
+ }
+ }
+ }
+ }
+
+ reloadUi()
+ if (savedInstanceState != null) {
+ setDropdownSelection(
+ binding.dropdownSystemType,
+ systemTypeDropdown,
+ savedInstanceState.getInt(SYS_TYPE)
+ )
+ setDropdownSelection(
+ binding.dropdownSystemRegion,
+ systemRegionDropdown,
+ savedInstanceState.getInt(REGION)
+ )
+ binding.dropdownSystemRegionStart
+ .setText(savedInstanceState.getString(REGION_START), false)
+ }
+
+ setInsets()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putInt(SYS_TYPE, systemTypeDropdown.position)
+ outState.putInt(REGION, systemRegionDropdown.position)
+ outState.putString(REGION_START, binding.dropdownSystemRegionStart.text.toString())
+ }
+
+ override fun onPause() {
+ super.onPause()
+ NativeLibrary.saveSystemConfig()
+ }
+
+ private fun reloadUi() {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ binding.switchRunSystemSetup.isChecked = NativeLibrary.getIsSystemSetupNeeded()
+ binding.switchRunSystemSetup.setOnCheckedChangeListener { _, isChecked ->
+ NativeLibrary.setSystemSetupNeeded(isChecked)
+ }
+
+ val showHomeApps = preferences.getBoolean(Settings.PREF_SHOW_HOME_APPS, false)
+ binding.switchShowApps.isChecked = showHomeApps
+ binding.switchShowApps.setOnCheckedChangeListener { _, isChecked ->
+ preferences.edit()
+ .putBoolean(Settings.PREF_SHOW_HOME_APPS, isChecked)
+ .apply()
+ gamesViewModel.setShouldSwapData(true)
+ }
+
+ if (!NativeLibrary.areKeysAvailable()) {
+ binding.apply {
+ systemType.isEnabled = false
+ systemRegion.isEnabled = false
+ buttonDownloadHomeMenu.isEnabled = false
+ textKeysMissing.visibility = View.VISIBLE
+ textKeysMissingHelp.visibility = View.VISIBLE
+ textKeysMissingHelp.text =
+ Html.fromHtml(getString(R.string.how_to_get_keys), Html.FROM_HTML_MODE_LEGACY)
+ textKeysMissingHelp.movementMethod = LinkMovementMethod.getInstance()
+ }
+ } else {
+ populateDownloadOptions()
+ }
+
+ binding.buttonDownloadHomeMenu.setOnClickListener {
+ val titleIds = NativeLibrary.getSystemTitleIds(
+ systemTypeDropdown.getValue(resources),
+ systemRegionDropdown.getValue(resources)
+ )
+
+ DownloadSystemFilesDialogFragment.newInstance(titleIds).show(
+ childFragmentManager,
+ DownloadSystemFilesDialogFragment.TAG
+ )
+ }
+
+ populateHomeMenuOptions()
+ binding.buttonStartHomeMenu.setOnClickListener {
+ val menuPath = homeMenuMap[binding.dropdownSystemRegionStart.text.toString()]!!
+ EmulationActivity.launch(requireActivity(), menuPath, getString(R.string.home_menu))
+ }
+ }
+
+ private fun populateDropdown(
+ dropdown: MaterialAutoCompleteTextView,
+ valuesId: Int,
+ dropdownItem: DropdownItem
+ ) {
+ val valuesAdapter = ArrayAdapter.createFromResource(
+ requireContext(),
+ valuesId,
+ R.layout.support_simple_spinner_dropdown_item
+ )
+ dropdown.setAdapter(valuesAdapter)
+ dropdown.onItemClickListener = dropdownItem
+ }
+
+ private fun setDropdownSelection(
+ dropdown: MaterialAutoCompleteTextView,
+ dropdownItem: DropdownItem,
+ selection: Int
+ ) {
+ if (dropdown.adapter != null) {
+ dropdown.setText(dropdown.adapter.getItem(selection).toString(), false)
+ }
+ dropdownItem.position = selection
+ }
+
+ private fun populateDownloadOptions() {
+ populateDropdown(binding.dropdownSystemType, R.array.systemFileTypes, systemTypeDropdown)
+ populateDropdown(
+ binding.dropdownSystemRegion,
+ R.array.systemFileRegions,
+ systemRegionDropdown
+ )
+
+ setDropdownSelection(
+ binding.dropdownSystemType,
+ systemTypeDropdown,
+ systemTypeDropdown.position
+ )
+ setDropdownSelection(
+ binding.dropdownSystemRegion,
+ systemRegionDropdown,
+ systemRegionDropdown.position
+ )
+ }
+
+ private fun populateHomeMenuOptions() {
+ regionValues = resources.getIntArray(R.array.systemFileRegionValues)
+ val regionEntries = resources.getStringArray(R.array.systemFileRegions)
+ regionValues.forEachIndexed { i: Int, region: Int ->
+ val regionString = regionEntries[i]
+ val regionPath = NativeLibrary.getHomeMenuPath(region)
+ homeMenuMap[regionString] = regionPath
+ }
+
+ val availableMenus = homeMenuMap.filter { it.value != "" }
+ if (availableMenus.isNotEmpty()) {
+ binding.systemRegionStart.isEnabled = true
+ binding.buttonStartHomeMenu.isEnabled = true
+
+ binding.dropdownSystemRegionStart.setAdapter(
+ ArrayAdapter(
+ requireContext(),
+ R.layout.support_simple_spinner_dropdown_item,
+ availableMenus.keys.toList()
+ )
+ )
+ binding.dropdownSystemRegionStart.setText(availableMenus.keys.first(), false)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.toolbarSystemFiles.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.toolbarSystemFiles.layoutParams = mlpAppBar
+
+ val mlpScrollSystemFiles =
+ binding.scrollSystemFiles.layoutParams as ViewGroup.MarginLayoutParams
+ mlpScrollSystemFiles.leftMargin = leftInsets
+ mlpScrollSystemFiles.rightMargin = rightInsets
+ binding.scrollSystemFiles.layoutParams = mlpScrollSystemFiles
+
+ binding.scrollSystemFiles.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java
deleted file mode 100644
index a4ffc59c7..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.citra.citra_emu.model;
-
-import android.content.ContentValues;
-import android.database.Cursor;
-
-import java.nio.file.Paths;
-
-public final class Game {
- private String mTitle;
- private String mDescription;
- private String mPath;
- private String mGameId;
- private String mCompany;
- private String mRegions;
-
- public Game(String title, String description, String regions, String path,
- String gameId, String company) {
- mTitle = title;
- mDescription = description;
- mRegions = regions;
- mPath = path;
- mGameId = gameId;
- mCompany = company;
- }
-
- public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) {
- ContentValues values = new ContentValues();
-
- if (gameId.isEmpty()) {
- // Homebrew, etc. may not have a game ID, use filename as a unique identifier
- gameId = Paths.get(path).getFileName().toString();
- }
-
- values.put(GameDatabase.KEY_GAME_TITLE, title);
- values.put(GameDatabase.KEY_GAME_DESCRIPTION, description);
- values.put(GameDatabase.KEY_GAME_REGIONS, regions);
- values.put(GameDatabase.KEY_GAME_PATH, path);
- values.put(GameDatabase.KEY_GAME_ID, gameId);
- values.put(GameDatabase.KEY_GAME_COMPANY, company);
-
- return values;
- }
-
- public static Game fromCursor(Cursor cursor) {
- return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE),
- cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION),
- cursor.getString(GameDatabase.GAME_COLUMN_REGIONS),
- cursor.getString(GameDatabase.GAME_COLUMN_PATH),
- cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
- cursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
- }
-
- public String getTitle() {
- return mTitle;
- }
-
- public String getDescription() {
- return mDescription;
- }
-
- public String getCompany() {
- return mCompany;
- }
-
- public String getRegions() {
- return mRegions;
- }
-
- public String getPath() {
- return mPath;
- }
-
- public String getGameId() {
- return mGameId;
- }
-}
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
new file mode 100644
index 000000000..d26d730d0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt
@@ -0,0 +1,59 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.model
+
+import android.os.Parcelable
+import java.util.HashSet
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+
+@Parcelize
+@Serializable
+class Game(
+ val title: String = "",
+ val description: String = "",
+ val path: String = "",
+ val titleId: Long = 0L,
+ val company: String = "",
+ val regions: String = "",
+ val isInstalled: Boolean = false,
+ val isSystemTitle: Boolean = false,
+ val isVisibleSystemTitle: Boolean = false,
+ val icon: IntArray? = null,
+ val filename: String
+) : Parcelable {
+ val keyAddedToLibraryTime get() = "${filename}_AddedToLibraryTime"
+ val keyLastPlayedTime get() = "${filename}_LastPlayed"
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is Game) {
+ return false
+ }
+
+ return hashCode() == other.hashCode()
+ }
+
+ override fun hashCode(): Int {
+ var result = title.hashCode()
+ result = 31 * result + description.hashCode()
+ result = 31 * result + regions.hashCode()
+ result = 31 * result + path.hashCode()
+ result = 31 * result + titleId.hashCode()
+ result = 31 * result + company.hashCode()
+ return result
+ }
+
+ companion object {
+ val allExtensions: Set get() = extensions + badExtensions
+
+ val extensions: Set = HashSet(
+ listOf("3ds", "3dsx", "elf", "axf", "cci", "cxi", "app")
+ )
+
+ val badExtensions: Set = HashSet(
+ listOf("rar", "zip", "7z", "torrent", "tar", "gz")
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
deleted file mode 100644
index cbbd8e32e..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
+++ /dev/null
@@ -1,279 +0,0 @@
-package org.citra.citra_emu.model;
-
-import android.content.ContentValues;
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteOpenHelper;
-import android.net.Uri;
-
-import org.citra.citra_emu.NativeLibrary;
-import org.citra.citra_emu.utils.FileUtil;
-import org.citra.citra_emu.utils.Log;
-
-import java.io.File;
-import java.io.IOException;
-import java.lang.reflect.Array;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-
-import rx.Observable;
-
-/**
- * A helper class that provides several utilities simplifying interaction with
- * the SQLite database.
- */
-public final class GameDatabase extends SQLiteOpenHelper {
- public static final int COLUMN_DB_ID = 0;
- public static final int GAME_COLUMN_PATH = 1;
- public static final int GAME_COLUMN_TITLE = 2;
- public static final int GAME_COLUMN_DESCRIPTION = 3;
- public static final int GAME_COLUMN_REGIONS = 4;
- public static final int GAME_COLUMN_GAME_ID = 5;
- public static final int GAME_COLUMN_COMPANY = 6;
- public static final int FOLDER_COLUMN_PATH = 1;
- public static final String KEY_DB_ID = "_id";
- public static final String KEY_GAME_PATH = "path";
- public static final String KEY_GAME_TITLE = "title";
- public static final String KEY_GAME_DESCRIPTION = "description";
- public static final String KEY_GAME_REGIONS = "regions";
- public static final String KEY_GAME_ID = "game_id";
- public static final String KEY_GAME_COMPANY = "company";
- public static final String KEY_FOLDER_PATH = "path";
- public static final String TABLE_NAME_FOLDERS = "folders";
- public static final String TABLE_NAME_GAMES = "games";
- private static final int DB_VERSION = 2;
- private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY";
- private static final String TYPE_INTEGER = " INTEGER";
- private static final String TYPE_STRING = " TEXT";
-
- private static final String CONSTRAINT_UNIQUE = " UNIQUE";
-
- private static final String SEPARATOR = ", ";
-
- private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "("
- + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
- + KEY_GAME_PATH + TYPE_STRING + SEPARATOR
- + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR
- + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR
- + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR
- + KEY_GAME_ID + TYPE_STRING + SEPARATOR
- + KEY_GAME_COMPANY + TYPE_STRING + ")";
-
- private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "("
- + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
- + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")";
-
- private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS;
- private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES;
- private final Context mContext;
-
- public GameDatabase(Context context) {
- // Superclass constructor builds a database or uses an existing one.
- super(context, "games.db", null, DB_VERSION);
- mContext = context;
- }
-
- @Override
- public void onCreate(SQLiteDatabase database) {
- Log.debug("[GameDatabase] GameDatabase - Creating database...");
-
- execSqlAndLog(database, SQL_CREATE_GAMES);
- execSqlAndLog(database, SQL_CREATE_FOLDERS);
- }
-
- @Override
- public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) {
- Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases..");
- execSqlAndLog(database, SQL_DELETE_FOLDERS);
- execSqlAndLog(database, SQL_CREATE_FOLDERS);
-
- execSqlAndLog(database, SQL_DELETE_GAMES);
- execSqlAndLog(database, SQL_CREATE_GAMES);
- }
-
- @Override
- public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) {
- Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " +
- newVersion);
-
- // Delete all the games
- execSqlAndLog(database, SQL_DELETE_GAMES);
- execSqlAndLog(database, SQL_CREATE_GAMES);
- }
-
- public void resetDatabase(SQLiteDatabase database) {
- execSqlAndLog(database, SQL_DELETE_FOLDERS);
- execSqlAndLog(database, SQL_CREATE_FOLDERS);
-
- execSqlAndLog(database, SQL_DELETE_GAMES);
- execSqlAndLog(database, SQL_CREATE_GAMES);
- }
-
- public void scanLibrary(SQLiteDatabase database) {
- // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing.
- Cursor fileCursor = database.query(TABLE_NAME_GAMES,
- null, // Get all columns.
- null, // Get all rows.
- null,
- null, // No grouping.
- null,
- null); // Order of games is irrelevant.
-
- // Possibly overly defensive, but ensures that moveToNext() does not skip a row.
- fileCursor.moveToPosition(-1);
-
- while (fileCursor.moveToNext()) {
- String gamePath = fileCursor.getString(GAME_COLUMN_PATH);
-
- if (!FileUtil.Exists(mContext, gamePath)) {
- Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " +
- gamePath);
- database.delete(TABLE_NAME_GAMES,
- KEY_DB_ID + " = ?",
- new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))});
- }
- }
-
- // Get a cursor listing all the folders the user has added to the library.
- Cursor folderCursor = database.query(TABLE_NAME_FOLDERS,
- null, // Get all columns.
- null, // Get all rows.
- null,
- null, // No grouping.
- null,
- null); // Order of folders is irrelevant.
-
- Set allowedExtensions = new HashSet(Arrays.asList(
- ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app", ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz"));
-
- // Possibly overly defensive, but ensures that moveToNext() does not skip a row.
- folderCursor.moveToPosition(-1);
-
- // Iterate through all results of the DB query (i.e. all folders in the library.)
- while (folderCursor.moveToNext()) {
- String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH);
-
- Uri folder = Uri.parse(folderPath);
- // If the folder is empty because it no longer exists, remove it from the library.
- CheapDocument[] files = FileUtil.listFiles(mContext, folder);
- if (files.length == 0) {
- Log.error(
- "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath);
- database.delete(TABLE_NAME_FOLDERS,
- KEY_DB_ID + " = ?",
- new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))});
- }
-
- addGamesRecursive(database, files, allowedExtensions, 3);
- }
-
- fileCursor.close();
- folderCursor.close();
-
- Arrays.stream(NativeLibrary.GetInstalledGamePaths())
- .forEach(filePath -> attemptToAddGame(database, filePath));
-
- database.close();
- }
-
- private void addGamesRecursive(SQLiteDatabase database, CheapDocument[] files,
- Set allowedExtensions, int depth) {
- if (depth <= 0) {
- return;
- }
-
- for (CheapDocument file : files) {
- if (file.isDirectory()) {
- Set newExtensions = new HashSet<>(Arrays.asList(
- ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app"));
- CheapDocument[] children = FileUtil.listFiles(mContext, file.getUri());
- this.addGamesRecursive(database, children, newExtensions, depth - 1);
- } else {
- String filename = file.getUri().toString();
-
- int extensionStart = filename.lastIndexOf('.');
- if (extensionStart > 0) {
- String fileExtension = filename.substring(extensionStart);
-
- // Check that the file has an extension we care about before trying to read out of it.
- if (allowedExtensions.contains(fileExtension.toLowerCase())) {
- attemptToAddGame(database, filename);
- }
- }
- }
- }
- }
-
- private static void attemptToAddGame(SQLiteDatabase database, String filePath) {
- GameInfo gameInfo;
- try {
- gameInfo = new GameInfo(filePath);
- } catch (IOException e) {
- gameInfo = null;
- }
-
- String name = gameInfo != null ? gameInfo.getTitle() : "";
-
- // If the game's title field is empty, use the filename.
- if (name.isEmpty()) {
- name = filePath.substring(filePath.lastIndexOf("/") + 1);
- }
-
- ContentValues game = Game.asContentValues(name,
- filePath.replace("\n", " "),
- gameInfo != null ? gameInfo.getRegions() : "Invalid region",
- filePath,
- filePath,
- gameInfo != null ? gameInfo.getCompany() : "");
-
- // Try to update an existing game first.
- int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update.
- game,
- // The values to fill the row with.
- KEY_GAME_ID + " = ?",
- // The WHERE clause used to find the right row.
- new String[]{game.getAsString(
- KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this,
- // which is provided as an array because there
- // could potentially be more than one argument.
-
- // If update fails, insert a new game instead.
- if (rowsMatched == 0) {
- Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE));
- database.insert(TABLE_NAME_GAMES, null, game);
- } else {
- Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE));
- }
- }
-
- public Observable getGames() {
- return Observable.create(subscriber ->
- {
- Log.info("[GameDatabase] Reading games list...");
-
- SQLiteDatabase database = getReadableDatabase();
- Cursor resultCursor = database.query(
- TABLE_NAME_GAMES,
- null,
- null,
- null,
- null,
- null,
- KEY_GAME_TITLE + " ASC"
- );
-
- // Pass the result cursor to the consumer.
- subscriber.onNext(resultCursor);
-
- // Tell the consumer we're done; it will unsubscribe implicitly.
- subscriber.onCompleted();
- });
- }
-
- private void execSqlAndLog(SQLiteDatabase database, String sql) {
- Log.verbose("[GameDatabase] Executing SQL: " + sql);
- database.execSQL(sql);
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java
deleted file mode 100644
index 35ce9947c..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.citra.citra_emu.model;
-
-import androidx.annotation.Keep;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-
-public class GameInfo {
- @Keep
- private final long mPointer;
-
- @Keep
- public GameInfo(String path) throws IOException {
- mPointer = initialize(path);
- if (mPointer == 0L) {
- throw new IOException();
- }
- }
-
- private static native long initialize(String path);
-
- @Override
- protected native void finalize();
-
- @NonNull
- public native String getTitle();
-
- @NonNull
- public native String getRegions();
-
- @NonNull
- public native String getCompany();
-
- @Nullable
- public native int[] getIcon();
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt
new file mode 100644
index 000000000..de2c1860c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt
@@ -0,0 +1,37 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.model
+
+import androidx.annotation.Keep
+import java.io.IOException
+
+class GameInfo(path: String) {
+ @Keep
+ private val pointer: Long
+
+ init {
+ pointer = initialize(path)
+ if (pointer == 0L) {
+ throw IOException()
+ }
+ }
+
+ protected external fun finalize()
+
+ external fun getTitle(): String
+
+ external fun getRegions(): String
+
+ external fun getCompany(): String
+
+ external fun getIcon(): IntArray?
+
+ external fun getIsVisibleSystemTitle(): Boolean
+
+ companion object {
+ @JvmStatic
+ private external fun initialize(path: String): Long
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java
deleted file mode 100644
index 33b289fc4..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java
+++ /dev/null
@@ -1,138 +0,0 @@
-package org.citra.citra_emu.model;
-
-import android.content.ContentProvider;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-
-import androidx.annotation.NonNull;
-
-import org.citra.citra_emu.BuildConfig;
-import org.citra.citra_emu.utils.Log;
-
-/**
- * Provides an interface allowing Activities to interact with the SQLite database.
- * CRUD methods in this class can be called by Activities using getContentResolver().
- */
-public final class GameProvider extends ContentProvider {
- public static final String REFRESH_LIBRARY = "refresh";
- public static final String RESET_LIBRARY = "reset";
-
- public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider";
- public static final Uri URI_FOLDER =
- Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/");
- public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/");
- public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/");
-
- public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder";
- public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game";
-
-
- private GameDatabase mDbHelper;
-
- @Override
- public boolean onCreate() {
- Log.info("[GameProvider] Creating Content Provider...");
-
- mDbHelper = new GameDatabase(getContext());
-
- return true;
- }
-
- @Override
- public Cursor query(@NonNull Uri uri, String[] projection, String selection,
- String[] selectionArgs, String sortOrder) {
- Log.info("[GameProvider] Querying URI: " + uri);
-
- SQLiteDatabase db = mDbHelper.getReadableDatabase();
-
- String table = uri.getLastPathSegment();
-
- if (table == null) {
- Log.error("[GameProvider] Badly formatted URI: " + uri);
- return null;
- }
-
- Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder);
- cursor.setNotificationUri(getContext().getContentResolver(), uri);
-
- return cursor;
- }
-
- @Override
- public String getType(@NonNull Uri uri) {
- Log.verbose("[GameProvider] Getting MIME type for URI: " + uri);
- String lastSegment = uri.getLastPathSegment();
-
- if (lastSegment == null) {
- Log.error("[GameProvider] Badly formatted URI: " + uri);
- return null;
- }
-
- if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) {
- return MIME_TYPE_FOLDER;
- } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) {
- return MIME_TYPE_GAME;
- }
-
- Log.error("[GameProvider] Unknown MIME type for URI: " + uri);
- return null;
- }
-
- @Override
- public Uri insert(@NonNull Uri uri, ContentValues values) {
- Log.info("[GameProvider] Inserting row at URI: " + uri);
-
- SQLiteDatabase database = mDbHelper.getWritableDatabase();
- String table = uri.getLastPathSegment();
-
- if (table != null) {
- if (table.equals(RESET_LIBRARY)) {
- mDbHelper.resetDatabase(database);
- return uri;
- }
- if (table.equals(REFRESH_LIBRARY)) {
- Log.info(
- "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents...");
- mDbHelper.scanLibrary(database);
- return uri;
- }
-
- long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE);
-
- // If insertion was successful...
- if (id > 0) {
- // If we just added a folder, add its contents to the game list.
- if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) {
- mDbHelper.scanLibrary(database);
- }
-
- // Notify the UI that its contents should be refreshed.
- getContext().getContentResolver().notifyChange(uri, null);
- uri = Uri.withAppendedPath(uri, Long.toString(id));
- } else {
- Log.error("[GameProvider] Row already exists: " + uri + " id: " + id);
- }
- } else {
- Log.error("[GameProvider] Badly formatted URI: " + uri);
- }
-
- database.close();
-
- return uri;
- }
-
- @Override
- public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
- Log.error("[GameProvider] Delete operations unsupported. URI: " + uri);
- return 0;
- }
-
- @Override
- public int update(@NonNull Uri uri, ContentValues values, String selection,
- String[] selectionArgs) {
- Log.error("[GameProvider] Update operations unsupported. URI: " + uri);
- return 0;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt
new file mode 100644
index 000000000..70a45c0ed
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt
@@ -0,0 +1,19 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.model
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+data class HomeSetting(
+ val titleId: Int,
+ val descriptionId: Int,
+ val iconId: Int,
+ val onClick: () -> Unit,
+ val isEnabled: () -> Boolean = { true },
+ val disabledTitleId: Int = 0,
+ val disabledMessageId: Int = 0,
+ val details: StateFlow = MutableStateFlow("")
+)
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt
new file mode 100644
index 000000000..b4115afe5
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt
@@ -0,0 +1,19 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.model
+
+import android.os.Parcelable
+import androidx.annotation.StringRes
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class License(
+ @StringRes val titleId: Int,
+ @StringRes val descriptionId: Int,
+ @StringRes val linkId: Int,
+ @StringRes val copyrightId: Int = 0,
+ @StringRes val licenseId: Int = 0,
+ @StringRes val licenseLinkId: Int = 0
+) : Parcelable
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt
new file mode 100644
index 000000000..c45f05cf8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt
@@ -0,0 +1,31 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.model
+
+data class SetupPage(
+ val iconId: Int,
+ val titleId: Int,
+ val descriptionId: Int,
+ val buttonIconId: Int,
+ val leftAlignedIcon: Boolean,
+ val buttonTextId: Int,
+ val buttonAction: (callback: SetupCallback) -> Unit,
+ val isUnskippable: Boolean = false,
+ val hasWarning: Boolean = false,
+ val stepCompleted: () -> StepState = { StepState.STEP_UNDEFINED },
+ val warningTitleId: Int = 0,
+ val warningDescriptionId: Int = 0,
+ val warningHelpLinkId: Int = 0
+)
+
+interface SetupCallback {
+ fun onStepCompleted()
+}
+
+enum class StepState {
+ STEP_COMPLETE,
+ STEP_INCOMPLETE,
+ STEP_UNDEFINED
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java
index f3e18afb2..e4d8da791 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java
@@ -347,7 +347,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (!button.updateStatus(event)) {
continue;
}
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus());
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus());
shouldUpdateView = true;
}
@@ -355,10 +355,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) {
continue;
}
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus());
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus());
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus());
- NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus());
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus());
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus());
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus());
+ NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus());
shouldUpdateView = true;
}
@@ -367,7 +367,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
continue;
}
int axisID = joystick.getJoystickId();
- NativeLibrary
+ NativeLibrary.INSTANCE
.onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis());
shouldUpdateView = true;
}
@@ -390,7 +390,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP;
if (isActionDown && !isTouchInputConsumed(pointerId)) {
- NativeLibrary.onTouchEvent(xPosition, yPosition, true);
+ NativeLibrary.INSTANCE.onTouchEvent(xPosition, yPosition, true);
}
if (isActionMove) {
@@ -399,12 +399,12 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (isTouchInputConsumed(fingerId)) {
continue;
}
- NativeLibrary.onTouchMoved(xPosition, yPosition);
+ NativeLibrary.INSTANCE.onTouchMoved(xPosition, yPosition);
}
}
if (isActionUp && !isTouchInputConsumed(pointerId)) {
- NativeLibrary.onTouchEvent(0, 0, false);
+ NativeLibrary.INSTANCE.onTouchEvent(0, 0, false);
}
return true;
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
deleted file mode 100644
index 0c295541c..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
+++ /dev/null
@@ -1,334 +0,0 @@
-package org.citra.citra_emu.ui.main;
-
-import android.Manifest;
-import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.widget.FrameLayout;
-import android.widget.Toast;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.widget.Toolbar;
-import androidx.core.content.ContextCompat;
-import androidx.core.splashscreen.SplashScreen;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import java.util.Collections;
-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.R;
-import org.citra.citra_emu.activities.EmulationActivity;
-import org.citra.citra_emu.contracts.OpenFileResultContract;
-import org.citra.citra_emu.features.settings.ui.SettingsActivity;
-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;
-import org.citra.citra_emu.utils.InsetsHelper;
-import org.citra.citra_emu.utils.PermissionsHandler;
-import org.citra.citra_emu.utils.PicassoUtils;
-import org.citra.citra_emu.utils.StartupHandler;
-import org.citra.citra_emu.utils.ThemeUtil;
-
-/**
- * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which
- * individually display a grid of available games for each Fragment, in a tabbed layout.
- */
-public final class MainActivity extends AppCompatActivity implements MainView {
- private Toolbar mToolbar;
- private int mFrameLayoutId;
- private PlatformGamesFragment mPlatformGamesFragment;
-
- private final MainPresenter mPresenter = new MainPresenter(this);
-
- // private final CiaInstallWorker mCiaInstallWorker = new CiaInstallWorker();
-
- // Singleton to manage user billing state
- private static BillingManager mBillingManager;
-
- private static MenuItem mPremiumButton;
-
- private final CitraDirectoryHelper citraDirectoryHelper = new CitraDirectoryHelper(this, () -> {
- // If mPlatformGamesFragment is null means game directory have not been set yet.
- if (mPlatformGamesFragment == null) {
- mPlatformGamesFragment = new PlatformGamesFragment();
- getSupportFragmentManager()
- .beginTransaction()
- .add(mFrameLayoutId, mPlatformGamesFragment)
- .commit();
- showGameInstallDialog();
- }
- });
-
- private final ActivityResultLauncher mOpenCitraDirectory =
- registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> {
- if (result == null)
- return;
- citraDirectoryHelper.showCitraDirectoryDialog(result);
- });
-
- private final ActivityResultLauncher mOpenGameListLauncher =
- registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> {
- if (result == null)
- return;
- int takeFlags =
- (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
- getContentResolver().takePersistableUriPermission(result, takeFlags);
- // When a new directory is picked, we currently will reset the existing games
- // database. This effectively means that only one game directory is supported.
- // TODO(bunnei): Consider fixing this in the future, or removing code for this.
- getContentResolver().insert(GameProvider.URI_RESET, null);
- // Add the new directory
- mPresenter.onDirectorySelected(result.toString());
- });
-
- private final ActivityResultLauncher mInstallCiaFileLauncher =
- registerForActivityResult(new OpenFileResultContract(), result -> {
- if (result == null)
- return;
- String[] selectedFiles = FileBrowserHelper.getSelectedFiles(
- result, getApplicationContext(), Collections.singletonList("cia"));
- if (selectedFiles == null) {
- Toast
- .makeText(getApplicationContext(), R.string.cia_file_not_found,
- Toast.LENGTH_LONG)
- .show();
- return;
- }
- 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()
- );
- });
-
- private final ActivityResultLauncher requestNotificationPermissionLauncher =
- registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { });
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
- splashScreen.setKeepOnScreenCondition(
- ()
- -> (PermissionsHandler.hasWriteAccess(this) &&
- !DirectoryInitialization.areCitraDirectoriesReady()));
-
- ThemeUtil.applyTheme(this);
-
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
-
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
-
- findViews();
-
- setSupportActionBar(mToolbar);
-
- mFrameLayoutId = R.id.games_platform_frame;
- mPresenter.onCreate();
-
- if (savedInstanceState == null) {
- StartupHandler.HandleInit(this, mOpenCitraDirectory);
- if (PermissionsHandler.hasWriteAccess(this)) {
- mPlatformGamesFragment = new PlatformGamesFragment();
- getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
- .commit();
- }
- } else {
- mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment");
- }
- PicassoUtils.init();
-
- // Setup billing manager, so we can globally query for Premium status
- mBillingManager = new BillingManager(this);
-
- // Dismiss previous notifications (should not happen unless a crash occurred)
- EmulationActivity.tryDismissRunningNotification(this);
-
- setInsets();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
- requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
- }
- }
- }
-
- @Override
- protected void onSaveInstanceState(@NonNull Bundle outState) {
- super.onSaveInstanceState(outState);
- if (PermissionsHandler.hasWriteAccess(this)) {
- if (getSupportFragmentManager() == null) {
- return;
- }
- if (outState == null) {
- return;
- }
- getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment);
- }
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- mPresenter.addDirIfNeeded(new AddDirectoryHelper(this));
-
- ThemeUtil.setSystemBarMode(this, ThemeUtil.getIsLightMode(getResources()));
- }
-
- // TODO: Replace with a ButterKnife injection.
- private void findViews() {
- mToolbar = findViewById(R.id.toolbar_main);
- }
-
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- MenuInflater inflater = getMenuInflater();
- inflater.inflate(R.menu.menu_game_grid, menu);
- mPremiumButton = menu.findItem(R.id.button_premium);
-
- if (mBillingManager.isPremiumCached()) {
- // User had premium in a previous session, hide upsell option
- setPremiumButtonVisible(false);
- }
-
- return true;
- }
-
- static public void setPremiumButtonVisible(boolean isVisible) {
- if (mPremiumButton != null) {
- mPremiumButton.setVisible(isVisible);
- }
- }
-
- /**
- * MainView
- */
-
- @Override
- public void setVersionString(String version) {
- mToolbar.setSubtitle(version);
- }
-
- @Override
- public void refresh() {
- getContentResolver().insert(GameProvider.URI_REFRESH, null);
- refreshFragment();
- }
-
- @Override
- public void launchSettingsActivity(String menuTag) {
- if (PermissionsHandler.hasWriteAccess(this)) {
- SettingsActivity.launch(this, menuTag, "");
- } else {
- PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory);
- }
- }
-
- @Override
- public void launchFileListActivity(int request) {
- if (PermissionsHandler.hasWriteAccess(this)) {
- switch (request) {
- case MainPresenter.REQUEST_SELECT_CITRA_DIRECTORY:
- mOpenCitraDirectory.launch(null);
- break;
- case MainPresenter.REQUEST_ADD_DIRECTORY:
- mOpenGameListLauncher.launch(null);
- break;
- case MainPresenter.REQUEST_INSTALL_CIA:
- mInstallCiaFileLauncher.launch(true);
- break;
- }
- } else {
- PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory);
- }
- }
-
- /**
- * Called by the framework whenever any actionbar/toolbar icon is clicked.
- *
- * @param item The icon that was clicked on.
- * @return True if the event was handled, false to bubble it up to the OS.
- */
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- return mPresenter.handleOptionSelection(item.getItemId());
- }
-
- private void refreshFragment() {
- if (mPlatformGamesFragment != null) {
- mPlatformGamesFragment.refresh();
- }
- }
-
- private void showGameInstallDialog() {
- new MaterialAlertDialogBuilder(this)
- .setIcon(R.mipmap.ic_launcher)
- .setTitle(R.string.app_name)
- .setMessage(R.string.app_game_install_description)
- .setCancelable(false)
- .setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(android.R.string.ok,
- (d, v) -> mOpenGameListLauncher.launch(null))
- .show();
- }
-
- @Override
- protected void onDestroy() {
- EmulationActivity.tryDismissRunningNotification(this);
- super.onDestroy();
- }
-
- /**
- * @return true if Premium subscription is currently active
- */
- public static boolean isPremiumActive() {
- return mBillingManager.isPremiumActive();
- }
-
- /**
- * Invokes the billing flow for Premium
- *
- * @param callback Optional callback, called once, on completion of billing
- */
- public static void invokePremiumBilling(Runnable callback) {
- mBillingManager.invokePremiumBilling(callback);
- }
-
- private void setInsets() {
- AppBarLayout appBar = findViewById(R.id.appbar);
- FrameLayout frame = findViewById(R.id.games_platform_frame);
- ViewCompat.setOnApplyWindowInsetsListener(frame, (v, windowInsets) -> {
- Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
- InsetsHelper.insetAppBar(insets, appBar);
- frame.setPadding(insets.left, 0, insets.right, 0);
- return windowInsets;
- });
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt
new file mode 100644
index 000000000..cb198f31e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt
@@ -0,0 +1,327 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.ui.main
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+import android.view.WindowManager
+import android.view.animation.PathInterpolator
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import androidx.preference.PreferenceManager
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.navigation.NavigationBarView
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import org.citra.citra_emu.R
+import org.citra.citra_emu.activities.EmulationActivity
+import org.citra.citra_emu.contracts.OpenFileResultContract
+import org.citra.citra_emu.databinding.ActivityMainBinding
+import org.citra.citra_emu.features.settings.model.Settings
+import org.citra.citra_emu.features.settings.ui.SettingsActivity
+import org.citra.citra_emu.features.settings.utils.SettingsFile
+import org.citra.citra_emu.fragments.SelectUserDirectoryDialogFragment
+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
+import org.citra.citra_emu.utils.InsetsHelper
+import org.citra.citra_emu.utils.PermissionsHandler
+import org.citra.citra_emu.utils.ThemeUtil
+import org.citra.citra_emu.viewmodel.GamesViewModel
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+
+ private val homeViewModel: HomeViewModel by viewModels()
+ private val gamesViewModel: GamesViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition {
+ !DirectoryInitialization.areCitraDirectoriesReady() &&
+ PermissionsHandler.hasWriteAccess(this)
+ }
+
+ ThemeUtil.setTheme(this)
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+
+ window.statusBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+ window.navigationBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+
+ binding.statusBarShade.setBackgroundColor(
+ ThemeUtil.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeUtil.SYSTEM_BAR_ALPHA
+ )
+ )
+ if (InsetsHelper.getSystemGestureType(applicationContext) !=
+ InsetsHelper.GESTURE_NAVIGATION
+ ) {
+ binding.navigationBarShade.setBackgroundColor(
+ ThemeUtil.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeUtil.SYSTEM_BAR_ALPHA
+ )
+ )
+ }
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+ setUpNavigation(navHostFragment.navController)
+ (binding.navigationView as NavigationBarView).setOnItemReselectedListener {
+ when (it.itemId) {
+ R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
+ R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
+ R.id.homeSettingsFragment -> SettingsActivity.launch(
+ this,
+ SettingsFile.FILE_NAME_CONFIG,
+ ""
+ )
+ }
+ }
+
+ // Prevents navigation from being drawn for a short time on recreation if set to hidden
+ if (!homeViewModel.navigationVisible.value.first) {
+ binding.navigationView.visibility = View.INVISIBLE
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+
+ lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.navigationVisible.collect {
+ showNavigation(it.first, it.second)
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.statusBarShadeVisible.collect {
+ showStatusBarShade(it)
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ homeViewModel.isPickingUserDir.collect { checkUserPermissions() }
+ }
+ }
+ }
+
+ // Dismiss previous notifications (should not happen unless a crash occurred)
+ EmulationActivity.tryDismissRunningNotification(this)
+
+ setInsets()
+ }
+
+ override fun onResume() {
+ checkUserPermissions()
+ super.onResume()
+ }
+
+ override fun onDestroy() {
+ EmulationActivity.tryDismissRunningNotification(this)
+ super.onDestroy()
+ }
+
+ private fun checkUserPermissions() {
+ val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
+
+ if (!firstTimeSetup && !PermissionsHandler.hasWriteAccess(this) &&
+ !homeViewModel.isPickingUserDir.value
+ ) {
+ SelectUserDirectoryDialogFragment.newInstance(this)
+ .show(supportFragmentManager, SelectUserDirectoryDialogFragment.TAG)
+ }
+ }
+
+ fun finishSetup(navController: NavController) {
+ navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ }
+
+ private fun setUpNavigation(navController: NavController) {
+ val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
+
+ if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
+ navController.navigate(R.id.firstTimeSetupFragment)
+ homeViewModel.navigatedToSetup = true
+ } else {
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ }
+ }
+
+ private fun showNavigation(visible: Boolean, animated: Boolean) {
+ if (!animated) {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ } else {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ return
+ }
+
+ val smallLayout = resources.getBoolean(R.bool.small_layout)
+ binding.navigationView.animate().apply {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ duration = 300
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+
+ if (smallLayout) {
+ binding.navigationView.translationY =
+ binding.navigationView.height.toFloat() * 2
+ translationY(0f)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) ==
+ ViewCompat.LAYOUT_DIRECTION_LTR
+ ) {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * -2
+ translationX(0f)
+ } else {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * 2
+ translationX(0f)
+ }
+ }
+ } else {
+ duration = 300
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+
+ if (smallLayout) {
+ translationY(binding.navigationView.height.toFloat() * 2)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) ==
+ ViewCompat.LAYOUT_DIRECTION_LTR
+ ) {
+ translationX(binding.navigationView.width.toFloat() * -2)
+ } else {
+ translationX(binding.navigationView.width.toFloat() * 2)
+ }
+ }
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ private fun showStatusBarShade(visible: Boolean) {
+ binding.statusBarShade.animate().apply {
+ if (visible) {
+ binding.statusBarShade.visibility = View.VISIBLE
+ binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
+ duration = 300
+ translationY(0f)
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+ } else {
+ duration = 300
+ translationY(binding.navigationView.height.toFloat() * -2)
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { _: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
+ mlpStatusShade.height = insets.top
+ binding.statusBarShade.layoutParams = mlpStatusShade
+
+ // 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 = insets.bottom
+ binding.navigationBarShade.layoutParams = mlpNavShade
+
+ windowInsets
+ }
+
+ val openCitraDirectory = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { result: Uri? ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ CitraDirectoryHelper(this@MainActivity).showCitraDirectoryDialog(result)
+ }
+
+ val ciaFileInstaller = registerForActivityResult(
+ OpenFileResultContract()
+ ) { result: Intent? ->
+ if (result == null) {
+ return@registerForActivityResult
+ }
+
+ val selectedFiles =
+ FileBrowserHelper.getSelectedFiles(result, applicationContext, listOf("cia"))
+ if (selectedFiles == null) {
+ Toast.makeText(applicationContext, R.string.cia_file_not_found, Toast.LENGTH_LONG)
+ .show()
+ return@registerForActivityResult
+ }
+
+ val workManager = WorkManager.getInstance(applicationContext)
+ workManager.enqueueUniqueWork(
+ "installCiaWork", ExistingWorkPolicy.APPEND_OR_REPLACE,
+ OneTimeWorkRequest.Builder(CiaInstallWorker::class.java)
+ .setInputData(
+ Data.Builder().putStringArray("CIA_FILES", selectedFiles)
+ .build()
+ )
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
deleted file mode 100644
index b25cbe53fe..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package org.citra.citra_emu.ui.main;
-
-import android.content.Context;
-import android.os.SystemClock;
-
-import org.citra.citra_emu.BuildConfig;
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.model.Settings;
-import org.citra.citra_emu.features.settings.utils.SettingsFile;
-import org.citra.citra_emu.model.GameDatabase;
-import org.citra.citra_emu.utils.AddDirectoryHelper;
-import org.citra.citra_emu.utils.PermissionsHandler;
-
-public final class MainPresenter {
- public static final int REQUEST_ADD_DIRECTORY = 1;
- public static final int REQUEST_INSTALL_CIA = 2;
- public static final int REQUEST_SELECT_CITRA_DIRECTORY = 3;
-
- private final MainView mView;
- private String mDirToAdd;
- private long mLastClickTime = 0;
-
- public MainPresenter(MainView view) {
- mView = view;
- }
-
- public void onCreate() {
- String versionName = BuildConfig.VERSION_NAME;
- mView.setVersionString(versionName);
- refreshGameList();
- }
-
- public void launchFileListActivity(int request) {
- if (mView != null) {
- mView.launchFileListActivity(request);
- }
- }
-
- public boolean handleOptionSelection(int itemId) {
- // Double-click prevention, using threshold of 500 ms
- if (SystemClock.elapsedRealtime() - mLastClickTime < 500) {
- return false;
- }
- mLastClickTime = SystemClock.elapsedRealtime();
-
- switch (itemId) {
- case R.id.menu_settings_core:
- mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG);
- return true;
-
- case R.id.button_select_root:
- mView.launchFileListActivity(REQUEST_SELECT_CITRA_DIRECTORY);
- return true;
-
- case R.id.button_add_directory:
- launchFileListActivity(REQUEST_ADD_DIRECTORY);
- return true;
-
- case R.id.button_install_cia:
- launchFileListActivity(REQUEST_INSTALL_CIA);
- return true;
-
- case R.id.button_premium:
- mView.launchSettingsActivity(Settings.SECTION_PREMIUM);
- return true;
- }
-
- return false;
- }
-
- public void addDirIfNeeded(AddDirectoryHelper helper) {
- if (mDirToAdd != null) {
- helper.addDirectory(mDirToAdd, mView::refresh);
-
- mDirToAdd = null;
- }
- }
-
- public void onDirectorySelected(String dir) {
- mDirToAdd = dir;
- }
-
- public void refreshGameList() {
- Context context = CitraApplication.getAppContext();
- if (PermissionsHandler.hasWriteAccess(context)) {
- GameDatabase databaseHelper = CitraApplication.databaseHelper;
- databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
- mView.refresh();
- }
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
deleted file mode 100644
index de7c04875..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package org.citra.citra_emu.ui.main;
-
-/**
- * Abstraction for the screen that shows on application launch.
- * Implementations will differ primarily to target touch-screen
- * or non-touch screen devices.
- */
-public interface MainView {
- /**
- * Pass the view the native library's version string. Displaying
- * it is optional.
- *
- * @param version A string pulled from native code.
- */
- void setVersionString(String version);
-
- /**
- * Tell the view to refresh its contents.
- */
- void refresh();
-
- void launchSettingsActivity(String menuTag);
-
- void launchFileListActivity(int request);
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
deleted file mode 100644
index 8ff938bee..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
+++ /dev/null
@@ -1,127 +0,0 @@
-package org.citra.citra_emu.ui.platform;
-
-
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-import android.database.Cursor;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import androidx.core.graphics.Insets;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
-import androidx.fragment.app.Fragment;
-import androidx.lifecycle.Lifecycle;
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-
-import com.google.android.material.color.MaterialColors;
-import com.google.android.material.divider.MaterialDividerItemDecoration;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.adapters.GameAdapter;
-import org.citra.citra_emu.model.GameDatabase;
-
-public final class PlatformGamesFragment extends Fragment implements PlatformGamesView {
- private final PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this);
-
- private GameAdapter mAdapter;
- private RecyclerView mRecyclerView;
- private TextView mTextView;
- private SwipeRefreshLayout mPullToRefresh;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View rootView = inflater.inflate(R.layout.fragment_grid, container, false);
-
- findViews(rootView);
-
- mPresenter.onCreateView();
-
- return rootView;
- }
-
- private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
- private final Handler mHandler = new Handler(Looper.getMainLooper());
-
- private void onPullToRefresh() {
- Runnable onPostRunnable = () -> {
- updateTextView();
- mPullToRefresh.setRefreshing(false);
- };
- Runnable scanLibraryRunnable = () -> {
- GameDatabase databaseHelper = CitraApplication.databaseHelper;
- databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
- mPresenter.refresh();
- mHandler.post(onPostRunnable);
- };
-
- mExecutor.execute(scanLibraryRunnable);
- }
-
- @Override
- public void onViewCreated(View view, Bundle savedInstanceState) {
- int columns = getResources().getInteger(R.integer.game_grid_columns);
- RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns);
- mAdapter = new GameAdapter();
-
- mRecyclerView.setLayoutManager(layoutManager);
- mRecyclerView.setAdapter(mAdapter);
- MaterialDividerItemDecoration divider = new MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL);
- divider.setLastItemDecorated(false);
- mRecyclerView.addItemDecoration(divider);
-
- // Add swipe down to refresh gesture
- mPullToRefresh.setOnRefreshListener(this::onPullToRefresh);
- mPullToRefresh.setProgressBackgroundColorSchemeColor(MaterialColors.getColor(mPullToRefresh, R.attr.colorPrimary));
- mPullToRefresh.setColorSchemeColors(MaterialColors.getColor(mPullToRefresh, R.attr.colorOnPrimary));
-
- setInsets();
- }
-
- @Override
- public void refresh() {
- mPresenter.refresh();
- updateTextView();
- }
-
- @Override
- public void showGames(Cursor games) {
- if (mAdapter != null) {
- mAdapter.swapCursor(games);
- }
- updateTextView();
- }
-
- private void updateTextView() {
- mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
- }
-
- private void findViews(View root) {
- mRecyclerView = root.findViewById(R.id.grid_games);
- mTextView = root.findViewById(R.id.gamelist_empty_text);
- mPullToRefresh = root.findViewById(R.id.refresh_grid_games);
- }
-
- private void setInsets() {
- ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> {
- Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
- v.setPadding(0, 0, 0, insets.bottom);
- return windowInsets;
- });
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
deleted file mode 100644
index 9d8040e1b..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package org.citra.citra_emu.ui.platform;
-
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.model.GameDatabase;
-import org.citra.citra_emu.utils.Log;
-
-import rx.android.schedulers.AndroidSchedulers;
-import rx.schedulers.Schedulers;
-
-public final class PlatformGamesPresenter {
- private final PlatformGamesView mView;
-
- public PlatformGamesPresenter(PlatformGamesView view) {
- mView = view;
- }
-
- public void onCreateView() {
- loadGames();
- }
-
- public void refresh() {
- Log.debug("[PlatformGamesPresenter] : Refreshing...");
- loadGames();
- }
-
- private void loadGames() {
- Log.debug("[PlatformGamesPresenter] : Loading games...");
-
- GameDatabase databaseHelper = CitraApplication.databaseHelper;
-
- databaseHelper.getGames()
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(games ->
- {
- Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor...");
-
- mView.showGames(games);
- });
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
deleted file mode 100644
index 4332121eb..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.citra.citra_emu.ui.platform;
-
-import android.database.Cursor;
-
-/**
- * Abstraction for a screen representing a single platform's games.
- */
-public interface PlatformGamesView {
- /**
- * Tell the view to refresh its contents.
- */
- void refresh();
-
- /**
- * To be called when an asynchronous database read completes. Passes the
- * result, in this case a {@link Cursor}, to the view.
- *
- * @param games A Cursor containing the games read from the database.
- */
- void showGames(Cursor games);
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
deleted file mode 100644
index 7578c353f..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.AsyncQueryHandler;
-import android.content.ContentValues;
-import android.content.Context;
-import android.net.Uri;
-
-import org.citra.citra_emu.model.GameDatabase;
-import org.citra.citra_emu.model.GameProvider;
-
-public class AddDirectoryHelper {
- private Context mContext;
-
- public AddDirectoryHelper(Context context) {
- this.mContext = context;
- }
-
- public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) {
- AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) {
- @Override
- protected void onInsertComplete(int token, Object cookie, Uri uri) {
- addDirectoryListener.onDirectoryAdded();
- }
- };
-
- ContentValues file = new ContentValues();
- file.put(GameDatabase.KEY_FOLDER_PATH, dir);
-
- handler.startInsert(0, // We don't need to identify this call to the handler
- null, // We don't need to pass additional data to the handler
- GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder
- file);
- }
-
- public interface AddDirectoryListener {
- void onDirectoryAdded();
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
deleted file mode 100644
index 5dc54c235..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
+++ /dev/null
@@ -1,215 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.app.Activity;
-import android.content.SharedPreferences;
-import android.preference.PreferenceManager;
-import android.widget.Toast;
-
-import com.android.billingclient.api.AcknowledgePurchaseParams;
-import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
-import com.android.billingclient.api.BillingClient;
-import com.android.billingclient.api.BillingClientStateListener;
-import com.android.billingclient.api.BillingFlowParams;
-import com.android.billingclient.api.BillingResult;
-import com.android.billingclient.api.Purchase;
-import com.android.billingclient.api.Purchase.PurchasesResult;
-import com.android.billingclient.api.PurchasesUpdatedListener;
-import com.android.billingclient.api.SkuDetails;
-import com.android.billingclient.api.SkuDetailsParams;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.utils.SettingsFile;
-import org.citra.citra_emu.ui.main.MainActivity;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class BillingManager implements PurchasesUpdatedListener {
- private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium";
-
- private final Activity mActivity;
- private BillingClient mBillingClient;
- private SkuDetails mSkuPremium;
- private boolean mIsPremiumActive = false;
- private boolean mIsServiceConnected = false;
- private Runnable mUpdateBillingCallback;
-
- private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
-
- public BillingManager(Activity activity) {
- mActivity = activity;
- mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build();
- querySkuDetails();
- }
-
- static public boolean isPremiumCached() {
- return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false);
- }
-
- /**
- * @return true if Premium subscription is currently active
- */
- public boolean isPremiumActive() {
- return mIsPremiumActive;
- }
-
- /**
- * Invokes the billing flow for Premium
- *
- * @param callback Optional callback, called once, on completion of billing
- */
- public void invokePremiumBilling(Runnable callback) {
- if (mSkuPremium == null) {
- return;
- }
-
- // Optional callback to refresh the UI for the caller when billing completes
- mUpdateBillingCallback = callback;
-
- // Invoke the billing flow
- BillingFlowParams flowParams = BillingFlowParams.newBuilder()
- .setSkuDetails(mSkuPremium)
- .build();
- mBillingClient.launchBillingFlow(mActivity, flowParams);
- }
-
- private void updatePremiumState(boolean isPremiumActive) {
- mIsPremiumActive = isPremiumActive;
-
- // Cache state for synchronous UI
- SharedPreferences.Editor editor = mPreferences.edit();
- editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive);
- editor.apply();
-
- // No need to show button in action bar if Premium is active
- MainActivity.setPremiumButtonVisible(!isPremiumActive);
- }
-
- @Override
- public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) {
- if (purchaseList == null || purchaseList.isEmpty()) {
- // Premium is not active, or billing is unavailable
- updatePremiumState(false);
- return;
- }
-
- Purchase premiumPurchase = null;
- for (Purchase purchase : purchaseList) {
- if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) {
- premiumPurchase = purchase;
- }
- }
-
- if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
- // Premium has been purchased
- updatePremiumState(true);
-
- // Acknowledge the purchase if it hasn't already been acknowledged.
- if (!premiumPurchase.isAcknowledged()) {
- AcknowledgePurchaseParams acknowledgePurchaseParams =
- AcknowledgePurchaseParams.newBuilder()
- .setPurchaseToken(premiumPurchase.getPurchaseToken())
- .build();
-
- AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> {
- Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show();
- };
- mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener);
- }
-
- if (mUpdateBillingCallback != null) {
- try {
- mUpdateBillingCallback.run();
- } catch (Exception e) {
- e.printStackTrace();
- }
- mUpdateBillingCallback = null;
- }
- }
- }
-
- private void onQuerySkuDetailsFinished(List skuDetailsList) {
- if (skuDetailsList == null) {
- // This can happen when no user is signed in
- return;
- }
-
- if (skuDetailsList.isEmpty()) {
- return;
- }
-
- mSkuPremium = skuDetailsList.get(0);
-
- queryPurchases();
- }
-
- private void querySkuDetails() {
- Runnable queryToExecute = new Runnable() {
- @Override
- public void run() {
- SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
- List skuList = new ArrayList<>();
-
- skuList.add(BILLING_SKU_PREMIUM);
- params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP);
-
- mBillingClient.querySkuDetailsAsync(params.build(),
- (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList));
- }
- };
-
- executeServiceRequest(queryToExecute);
- }
-
- private void onQueryPurchasesFinished(PurchasesResult result) {
- // Have we been disposed of in the meantime? If so, or bad result code, then quit
- if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) {
- updatePremiumState(false);
- return;
- }
- // Update the UI and purchases inventory with new list of purchases
- onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList());
- }
-
- private void queryPurchases() {
- Runnable queryToExecute = new Runnable() {
- @Override
- public void run() {
- final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP);
- onQueryPurchasesFinished(purchasesResult);
- }
- };
-
- executeServiceRequest(queryToExecute);
- }
-
- private void startServiceConnection(final Runnable executeOnFinish) {
- mBillingClient.startConnection(new BillingClientStateListener() {
- @Override
- public void onBillingSetupFinished(BillingResult billingResult) {
- if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
- mIsServiceConnected = true;
- }
-
- if (executeOnFinish != null) {
- executeOnFinish.run();
- }
- }
-
- @Override
- public void onBillingServiceDisconnected() {
- mIsServiceConnected = false;
- }
- });
- }
-
- private void executeServiceRequest(Runnable runnable) {
- if (mIsServiceConnected) {
- runnable.run();
- } else {
- // If billing service was disconnected, we try to reconnect 1 time.
- startServiceConnection(runnable);
- }
- }
-}
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
index 2f7ca66c2..22f58ea4f 100644
--- 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
@@ -5,6 +5,7 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
+import android.net.Uri;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -13,6 +14,7 @@ import androidx.work.ForegroundInfo;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
+import org.citra.citra_emu.NativeLibrary.InstallStatus;
import org.citra.citra_emu.R;
public class CiaInstallWorker extends Worker {
@@ -56,15 +58,6 @@ public class CiaInstallWorker extends Worker {
super(context, params);
}
- enum InstallStatus {
- Success,
- ErrorFailedToOpenFile,
- ErrorFileNotFound,
- ErrorAborted,
- ErrorInvalid,
- ErrorEncrypted,
- }
-
private void notifyInstallStatus(String filename, InstallStatus status) {
switch(status){
case Success:
@@ -126,10 +119,10 @@ public class CiaInstallWorker extends Worker {
int i = 0;
for (String file : selectedFiles) {
- String filename = FileUtil.getFilename(mContext, file);
+ String filename = FileUtil.getFilename(Uri.parse(file));
mInstallProgressBuilder.setContentText(mContext.getString(
R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length));
- InstallStatus res = InstallCIA(file);
+ InstallStatus res = installCIA(file);
notifyInstallStatus(filename, res);
}
mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID);
@@ -156,5 +149,5 @@ public class CiaInstallWorker extends Worker {
return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build());
}
- private native InstallStatus InstallCIA(String path);
+ private native InstallStatus installCIA(String path);
}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java
deleted file mode 100644
index 5a3ff6119..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.Intent;
-import android.net.Uri;
-import androidx.fragment.app.FragmentActivity;
-import java.util.concurrent.Executors;
-import org.citra.citra_emu.dialogs.CitraDirectoryDialog;
-import org.citra.citra_emu.dialogs.CopyDirProgressDialog;
-
-/**
- * Citra directory initialization ui flow controller.
- */
-public class CitraDirectoryHelper {
- public interface Listener {
- void onDirectoryInitialized();
- }
-
- private final FragmentActivity mFragmentActivity;
- private final Listener mListener;
-
- public CitraDirectoryHelper(FragmentActivity mFragmentActivity, Listener mListener) {
- this.mFragmentActivity = mFragmentActivity;
- this.mListener = mListener;
- }
-
- public void showCitraDirectoryDialog(Uri result) {
- CitraDirectoryDialog citraDirectoryDialog = CitraDirectoryDialog.newInstance(
- result.toString(), ((moveData, path) -> {
- Uri previous = PermissionsHandler.getCitraDirectory();
- // Do noting if user select the previous path.
- if (path.equals(previous)) {
- return;
- }
- int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
- Intent.FLAG_GRANT_READ_URI_PERMISSION);
- mFragmentActivity.getContentResolver().takePersistableUriPermission(path,
- takeFlags);
- if (!moveData || previous == null) {
- initializeCitraDirectory(path);
- mListener.onDirectoryInitialized();
- return;
- }
-
- // If user check move data, show copy progress dialog.
- showCopyDialog(previous, path);
- }));
- citraDirectoryDialog.show(mFragmentActivity.getSupportFragmentManager(),
- CitraDirectoryDialog.TAG);
- }
-
- private void showCopyDialog(Uri previous, Uri path) {
- CopyDirProgressDialog copyDirProgressDialog = new CopyDirProgressDialog();
- copyDirProgressDialog.showNow(mFragmentActivity.getSupportFragmentManager(),
- CopyDirProgressDialog.TAG);
-
- // Run copy dir in background
- Executors.newSingleThreadExecutor().execute(() -> {
- FileUtil.copyDir(
- mFragmentActivity, previous.toString(), path.toString(),
- new FileUtil.CopyDirListener() {
- @Override
- public void onSearchProgress(String directoryName) {
- copyDirProgressDialog.onUpdateSearchProgress(directoryName);
- }
-
- @Override
- public void onCopyProgress(String filename, int progress, int max) {
- copyDirProgressDialog.onUpdateCopyProgress(filename, progress, max);
- }
-
- @Override
- public void onComplete() {
- initializeCitraDirectory(path);
- copyDirProgressDialog.dismissAllowingStateLoss();
- mListener.onDirectoryInitialized();
- }
- });
- });
- }
-
- private void initializeCitraDirectory(Uri path) {
- if (!PermissionsHandler.setCitraDirectory(path.toString()))
- return;
- DirectoryInitialization.resetCitraDirectoryState();
- DirectoryInitialization.start(mFragmentActivity);
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt
new file mode 100644
index 000000000..0b6c91a98
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt
@@ -0,0 +1,63 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.Intent
+import android.net.Uri
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModelProvider
+import org.citra.citra_emu.fragments.CitraDirectoryDialogFragment
+import org.citra.citra_emu.fragments.CopyDirProgressDialog
+import org.citra.citra_emu.model.SetupCallback
+import org.citra.citra_emu.viewmodel.HomeViewModel
+
+/**
+ * Citra directory initialization ui flow controller.
+ */
+class CitraDirectoryHelper(private val fragmentActivity: FragmentActivity) {
+ fun showCitraDirectoryDialog(result: Uri, callback: SetupCallback? = null) {
+ val citraDirectoryDialog = CitraDirectoryDialogFragment.newInstance(
+ fragmentActivity,
+ result.toString(),
+ CitraDirectoryDialogFragment.Listener { moveData: Boolean, path: Uri ->
+ val previous = PermissionsHandler.citraDirectory
+ // Do noting if user select the previous path.
+ if (path == previous) {
+ return@Listener
+ }
+
+ val takeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ fragmentActivity.contentResolver.takePersistableUriPermission(
+ path,
+ takeFlags
+ )
+ if (!moveData || previous.toString().isEmpty()) {
+ initializeCitraDirectory(path)
+ callback?.onStepCompleted()
+ val viewModel = ViewModelProvider(fragmentActivity)[HomeViewModel::class.java]
+ viewModel.setUserDir(fragmentActivity, path.path!!)
+ viewModel.setPickingUserDir(false)
+ return@Listener
+ }
+
+ // If user check move data, show copy progress dialog.
+ CopyDirProgressDialog.newInstance(fragmentActivity, previous, path, callback)
+ ?.show(fragmentActivity.supportFragmentManager, CopyDirProgressDialog.TAG)
+ })
+ citraDirectoryDialog.show(
+ fragmentActivity.supportFragmentManager,
+ CitraDirectoryDialogFragment.TAG
+ )
+ }
+
+ companion object {
+ fun initializeCitraDirectory(path: Uri) {
+ PermissionsHandler.setCitraDirectory(path.toString())
+ DirectoryInitialization.resetCitraDirectoryState()
+ DirectoryInitialization.start()
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
deleted file mode 100644
index 5de5d9a74..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * Copyright 2014 Dolphin Emulator Project
- * Licensed under GPLv2+
- * Refer to the license.txt file included.
- */
-
-package org.citra.citra_emu.utils;
-
-import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.Uri;
-import android.os.Environment;
-import android.preference.PreferenceManager;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.NativeLibrary;
-
-/**
- * A service that spawns its own thread in order to copy several binary and shader files
- * from the Citra APK to the external file system.
- */
-public final class DirectoryInitialization {
- public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST";
-
- public static final String EXTRA_STATE = "directoryState";
- private static volatile DirectoryInitializationState directoryState = null;
- private static String userPath;
- private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false);
-
- public static void start(Context context) {
- // Can take a few seconds to run, so don't block UI thread.
- //noinspection TrivialFunctionalExpressionUsage
- ((Runnable) () -> init(context)).run();
- }
-
- private static void init(Context context) {
- if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true))
- return;
-
- if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
- if (PermissionsHandler.hasWriteAccess(context)) {
- if (setCitraUserDirectory()) {
- initializeInternalStorage(context);
- CitraApplication.documentsTree.setRoot(Uri.parse(userPath));
- NativeLibrary.CreateLogFile();
- NativeLibrary.LogUserDirectory(userPath);
- NativeLibrary.CreateConfigFile();
- directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
- } else {
- directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE;
- }
- } else {
- directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED;
- }
- }
-
- isCitraDirectoryInitializationRunning.set(false);
- sendBroadcastState(directoryState, context);
- }
-
- private static void deleteDirectoryRecursively(File file) {
- if (file.isDirectory()) {
- for (File child : file.listFiles())
- deleteDirectoryRecursively(child);
- }
- file.delete();
- }
-
- public static boolean areCitraDirectoriesReady() {
- return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
- }
-
- public static void resetCitraDirectoryState() {
- directoryState = null;
- isCitraDirectoryInitializationRunning.compareAndSet(true, false);
- }
-
- public static String getUserDirectory() {
- if (directoryState == null) {
- throw new IllegalStateException("DirectoryInitialization has to run at least once!");
- } else if (isCitraDirectoryInitializationRunning.get()) {
- throw new IllegalStateException(
- "DirectoryInitialization has to finish running first!");
- }
- return userPath;
- }
-
- private static native void SetSysDirectory(String path);
-
- private static boolean setCitraUserDirectory() {
- Uri dataPath = PermissionsHandler.getCitraDirectory();
- if (dataPath != null) {
- userPath = dataPath.toString();
- Log.debug("[DirectoryInitialization] User Dir: " + userPath);
- return true;
- }
-
- return false;
- }
-
- private static void initializeInternalStorage(Context context) {
- File sysDirectory = new File(context.getFilesDir(), "Sys");
-
- SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
- String revision = NativeLibrary.GetGitRevision();
- if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) {
- // There is no extracted Sys directory, or there is a Sys directory from another
- // version of Citra that might contain outdated files. Let's (re-)extract Sys.
- deleteDirectoryRecursively(sysDirectory);
- copyAssetFolder("Sys", sysDirectory, true, context);
-
- SharedPreferences.Editor editor = preferences.edit();
- editor.putString("sysDirectoryVersion", revision);
- editor.apply();
- }
-
- // Let the native code know where the Sys directory is.
- SetSysDirectory(sysDirectory.getPath());
- }
-
- private static void sendBroadcastState(DirectoryInitializationState state, Context context) {
- Intent localIntent =
- new Intent(BROADCAST_ACTION)
- .putExtra(EXTRA_STATE, state);
- LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent);
- }
-
- private static void copyAsset(String asset, File output, Boolean overwrite, Context context) {
- Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output);
-
- try {
- if (!output.exists() || overwrite) {
- InputStream in = context.getAssets().open(asset);
- OutputStream out = new FileOutputStream(output);
- copyFile(in, out);
- in.close();
- out.close();
- }
- } catch (IOException e) {
- Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset +
- e.getMessage());
- }
- }
-
- private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite,
- Context context) {
- Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " +
- outputFolder);
-
- try {
- boolean createdFolder = false;
- for (String file : context.getAssets().list(assetFolder)) {
- if (!createdFolder) {
- outputFolder.mkdir();
- createdFolder = true;
- }
- copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file),
- overwrite, context);
- copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite,
- context);
- }
- } catch (IOException e) {
- Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder +
- e.getMessage());
- }
- }
-
- private static void copyFile(InputStream in, OutputStream out) throws IOException {
- byte[] buffer = new byte[1024];
- int read;
-
- while ((read = in.read(buffer)) != -1) {
- out.write(buffer, 0, read);
- }
- }
-
- public enum DirectoryInitializationState {
- CITRA_DIRECTORIES_INITIALIZED,
- EXTERNAL_STORAGE_PERMISSION_NEEDED,
- CANT_FIND_EXTERNAL_STORAGE
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt
new file mode 100644
index 000000000..10e509f23
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt
@@ -0,0 +1,163 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.Context
+import android.net.Uri
+import androidx.preference.PreferenceManager
+import org.citra.citra_emu.BuildConfig
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.NativeLibrary
+import org.citra.citra_emu.utils.PermissionsHandler.hasWriteAccess
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A service that spawns its own thread in order to copy several binary and shader files
+ * from the Citra APK to the external file system.
+ */
+object DirectoryInitialization {
+ private const val SYS_DIR_VERSION = "sysDirectoryVersion"
+
+ @Volatile
+ private var directoryState: DirectoryInitializationState? = null
+ var userPath: String? = null
+ val internalUserPath
+ get() = CitraApplication.appContext.getExternalFilesDir(null)!!.canonicalPath
+ private val isCitraDirectoryInitializationRunning = AtomicBoolean(false)
+
+ val context: Context get() = CitraApplication.appContext
+
+ @JvmStatic
+ fun start(): DirectoryInitializationState? {
+ if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true)) {
+ return null
+ }
+
+ if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
+ directoryState = if (hasWriteAccess(context)) {
+ if (setCitraUserDirectory()) {
+ CitraApplication.documentsTree.setRoot(Uri.parse(userPath))
+ NativeLibrary.createLogFile()
+ NativeLibrary.logUserDirectory(userPath.toString())
+ NativeLibrary.createConfigFile()
+ GpuDriverHelper.initializeDriverParameters()
+ DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED
+ } else {
+ DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE
+ }
+ } else {
+ DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED
+ }
+ }
+ isCitraDirectoryInitializationRunning.set(false)
+ return directoryState
+ }
+
+ private fun deleteDirectoryRecursively(file: File) {
+ if (file.isDirectory) {
+ for (child in file.listFiles()!!) {
+ deleteDirectoryRecursively(child)
+ }
+ }
+ file.delete()
+ }
+
+ @JvmStatic
+ fun areCitraDirectoriesReady(): Boolean {
+ return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED
+ }
+
+ fun resetCitraDirectoryState() {
+ directoryState = null
+ isCitraDirectoryInitializationRunning.compareAndSet(true, false)
+ }
+
+ val userDirectory: String?
+ get() {
+ checkNotNull(directoryState) {
+ "DirectoryInitialization has to run at least once!"
+ }
+ check(!isCitraDirectoryInitializationRunning.get()) {
+ "DirectoryInitialization has to finish running first!"
+ }
+ return userPath
+ }
+
+ fun setCitraUserDirectory(): Boolean {
+ val dataPath = PermissionsHandler.citraDirectory
+ if (dataPath.toString().isNotEmpty()) {
+ userPath = dataPath.toString()
+ Log.debug("[DirectoryInitialization] User Dir: $userPath")
+ return true
+ }
+ return false
+ }
+
+ private fun copyAsset(asset: String, output: File, overwrite: Boolean, context: Context) {
+ Log.verbose("[DirectoryInitialization] Copying File $asset to $output")
+ try {
+ if (!output.exists() || overwrite) {
+ val inputStream = context.assets.open(asset)
+ val outputStream = FileOutputStream(output)
+ copyFile(inputStream, outputStream)
+ inputStream.close()
+ outputStream.close()
+ }
+ } catch (e: IOException) {
+ Log.error("[DirectoryInitialization] Failed to copy asset file: $asset" + e.message)
+ }
+ }
+
+ private fun copyAssetFolder(
+ assetFolder: String,
+ outputFolder: File,
+ overwrite: Boolean,
+ context: Context
+ ) {
+ Log.verbose("[DirectoryInitialization] Copying Folder $assetFolder to $outputFolder")
+ try {
+ var createdFolder = false
+ for (file in context.assets.list(assetFolder)!!) {
+ if (!createdFolder) {
+ outputFolder.mkdir()
+ createdFolder = true
+ }
+ copyAssetFolder(
+ assetFolder + File.separator + file, File(outputFolder, file),
+ overwrite, context
+ )
+ copyAsset(
+ assetFolder + File.separator + file, File(outputFolder, file), overwrite,
+ context
+ )
+ }
+ } catch (e: IOException) {
+ Log.error(
+ "[DirectoryInitialization] Failed to copy asset folder: $assetFolder" +
+ e.message
+ )
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
+ val buffer = ByteArray(1024)
+ var read: Int
+ while (inputStream.read(buffer).also { read = it } != -1) {
+ outputStream.write(buffer, 0, read)
+ }
+ }
+
+ enum class DirectoryInitializationState {
+ CITRA_DIRECTORIES_INITIALIZED,
+ EXTERNAL_STORAGE_PERMISSION_NEEDED,
+ CANT_FIND_EXTERNAL_STORAGE
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
deleted file mode 100644
index 5d1e951ca..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-
-import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
-
-public class DirectoryStateReceiver extends BroadcastReceiver {
- Action1 callback;
-
- public DirectoryStateReceiver(Action1 callback) {
- this.callback = callback;
- }
-
- @Override
- public void onReceive(Context context, Intent intent) {
- DirectoryInitializationState state = (DirectoryInitializationState) intent
- .getSerializableExtra(DirectoryInitialization.EXTRA_STATE);
- callback.call(state);
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java
index 527875249..5897328ae 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java
@@ -12,6 +12,7 @@ import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
+import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
@@ -25,6 +26,7 @@ import org.citra.citra_emu.utils.Log;
import java.util.Objects;
+@Keep
public class DiskShaderCacheProgress {
// Equivalent to VideoCore::LoadCallbackStage
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java
deleted file mode 100644
index 7cf030748..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.Context;
-import android.net.Uri;
-import android.provider.DocumentsContract;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.documentfile.provider.DocumentFile;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.model.CheapDocument;
-
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.URLDecoder;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.StringTokenizer;
-
-/**
- * A cached document tree for citra user directory.
- * For every filepath which is not startsWith "content://" will need to use this class to traverse.
- * For example:
- * C++ citra log file directory will be /log/citra_log.txt.
- * After DocumentsTree.resolvePath() it will become content URI.
- */
-public class DocumentsTree {
- private DocumentsNode root;
- private final Context context;
- public static final String DELIMITER = "/";
-
- public DocumentsTree() {
- context = CitraApplication.getAppContext();
- }
-
- public void setRoot(Uri rootUri) {
- root = null;
- root = new DocumentsNode();
- root.uri = rootUri;
- root.isDirectory = true;
- }
-
- public boolean createFile(String filepath, String name) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) return false;
- if (!node.isDirectory) return false;
- if (!node.loaded) structTree(node);
- Uri mUri = node.uri;
- try {
- String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
- if (node.findChild(filename) != null) return true;
- DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name);
- if (createdFile == null) return false;
- DocumentsNode document = new DocumentsNode(createdFile, false);
- document.parent = node;
- node.addChild(document);
- return true;
- } catch (Exception e) {
- Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
- }
- return false;
- }
-
- public boolean createDir(String filepath, String name) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) return false;
- if (!node.isDirectory) return false;
- if (!node.loaded) structTree(node);
- Uri mUri = node.uri;
- try {
- String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
- if (node.findChild(filename) != null) return true;
- DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name);
- if (createdDirectory == null) return false;
- DocumentsNode document = new DocumentsNode(createdDirectory, true);
- document.parent = node;
- node.addChild(document);
- return true;
- } catch (Exception e) {
- Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
- }
- return false;
- }
-
- public int openContentUri(String filepath, String openmode) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) {
- return -1;
- }
- return FileUtil.openContentUri(context, node.uri.toString(), openmode);
- }
-
- public String getFilename(String filepath) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) {
- return "";
- }
- return node.name;
- }
-
- public String[] getFilesName(String filepath) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null || !node.isDirectory) {
- return new String[0];
- }
- // If this directory have not been iterate struct it.
- if (!node.loaded) structTree(node);
- return node.getChildNames();
- }
-
- public long getFileSize(String filepath) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null || node.isDirectory) {
- return 0;
- }
- return FileUtil.getFileSize(context, node.uri.toString());
- }
-
- public boolean isDirectory(String filepath) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) return false;
- return node.isDirectory;
- }
-
- public boolean Exists(String filepath) {
- return resolvePath(filepath) != null;
- }
-
- public boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) {
- DocumentsNode sourceNode = resolvePath(sourcePath);
- if (sourceNode == null) return false;
- DocumentsNode destinationNode = resolvePath(destinationParentPath);
- if (destinationNode == null) return false;
- try {
- DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationNode.uri);
- if (destinationParent == null) return false;
- String filename = URLDecoder.decode(destinationFilename, "UTF-8");
- DocumentFile destination = destinationParent.createFile("application/octet-stream", filename);
- if (destination == null) return false;
- DocumentsNode document = new DocumentsNode();
- document.uri = destination.getUri();
- document.parent = destinationNode;
- document.name = destination.getName();
- document.isDirectory = destination.isDirectory();
- document.loaded = true;
- InputStream input = context.getContentResolver().openInputStream(sourceNode.uri);
- OutputStream output = context.getContentResolver().openOutputStream(destination.getUri(), "wt");
- byte[] buffer = new byte[1024];
- int len;
- while ((len = input.read(buffer)) != -1) {
- output.write(buffer, 0, len);
- }
- input.close();
- output.flush();
- output.close();
- destinationNode.addChild(document);
- return true;
- } catch (Exception e) {
- Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage());
- }
- return false;
- }
-
- public boolean renameFile(String filepath, String destinationFilename) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) return false;
- try {
- Uri mUri = node.uri;
- String filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD);
- DocumentsContract.renameDocument(context.getContentResolver(), mUri, filename);
- node.rename(filename);
- return true;
- } catch (Exception e) {
- Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
- }
- return false;
- }
-
- public boolean deleteDocument(String filepath) {
- DocumentsNode node = resolvePath(filepath);
- if (node == null) return false;
- try {
- Uri mUri = node.uri;
- if (!DocumentsContract.deleteDocument(context.getContentResolver(), mUri)) {
- return false;
- }
- if (node.parent != null) {
- node.parent.removeChild(node);
- }
- return true;
- } catch (Exception e) {
- Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage());
- }
- return false;
- }
-
- @Nullable
- private DocumentsNode resolvePath(String filepath) {
- if (root == null)
- return null;
- StringTokenizer tokens = new StringTokenizer(filepath, DELIMITER, false);
- DocumentsNode iterator = root;
- while (tokens.hasMoreTokens()) {
- String token = tokens.nextToken();
- if (token.isEmpty()) continue;
- iterator = find(iterator, token);
- if (iterator == null) return null;
- }
- return iterator;
- }
-
- @Nullable
- private DocumentsNode find(DocumentsNode parent, String filename) {
- if (parent.isDirectory && !parent.loaded) {
- structTree(parent);
- }
- return parent.findChild(filename);
- }
-
- /**
- * Construct current level directory tree
- *
- * @param parent parent node of this level
- */
- private void structTree(DocumentsNode parent) {
- CheapDocument[] documents = FileUtil.listFiles(context, parent.uri);
- for (CheapDocument document : documents) {
- DocumentsNode node = new DocumentsNode(document);
- node.parent = parent;
- parent.addChild(node);
- }
- parent.loaded = true;
- }
-
- @NonNull
- private static String toLowerCase(@NonNull String str) {
- return str.toLowerCase(Locale.ROOT);
- }
-
- private static class DocumentsNode {
- private DocumentsNode parent;
- private final Map children = new HashMap<>();
- private String name;
- private Uri uri;
- private boolean loaded = false;
- private boolean isDirectory = false;
-
- private DocumentsNode() {}
-
- private DocumentsNode(CheapDocument document) {
- name = document.getFilename();
- uri = document.getUri();
- isDirectory = document.isDirectory();
- loaded = !isDirectory;
- }
-
- private DocumentsNode(DocumentFile document, boolean isCreateDir) {
- name = document.getName();
- uri = document.getUri();
- isDirectory = isCreateDir;
- loaded = true;
- }
-
- private void rename(String name) {
- if (parent == null) {
- return;
- }
- parent.removeChild(this);
- this.name = name;
- parent.addChild(this);
- }
-
- private void addChild(DocumentsNode node) {
- children.put(toLowerCase(node.name), node);
- }
-
- private void removeChild(DocumentsNode node) {
- children.remove(toLowerCase(node.name));
- }
-
- @Nullable
- private DocumentsNode findChild(String filename) {
- return children.get(toLowerCase(filename));
- }
-
- @NonNull
- private String[] getChildNames() {
- String[] names = new String[children.size()];
-
- int i = 0;
- for (DocumentsNode child : children.values()) {
- names[i++] = child.name;
- }
-
- return names;
- }
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt
new file mode 100644
index 000000000..f2512f148
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt
@@ -0,0 +1,275 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.documentfile.provider.DocumentFile
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.model.CheapDocument
+import java.net.URLDecoder
+import java.util.StringTokenizer
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * A cached document tree for Citra user directory.
+ * For every filepath which is not startsWith "content://" will need to use this class to traverse.
+ * For example:
+ * C++ Citra log file directory will be /log/citra_log.txt.
+ * After DocumentsTree.resolvePath() it will become content URI.
+ */
+class DocumentsTree {
+ private var root: DocumentsNode? = null
+ private val context get() = CitraApplication.appContext
+
+ fun setRoot(rootUri: Uri?) {
+ root = null
+ root = DocumentsNode()
+ root!!.uri = rootUri
+ root!!.isDirectory = true
+ }
+
+ @Synchronized
+ fun createFile(filepath: String, name: String): Boolean {
+ val node = resolvePath(filepath) ?: return false
+ if (!node.isDirectory) return false
+ if (!node.loaded) structTree(node)
+ try {
+ val filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD)
+ if (node.findChild(filename) != null) return true
+ val createdFile = FileUtil.createFile(node.uri.toString(), name) ?: return false
+ val document = DocumentsNode(createdFile, false)
+ document.parent = node
+ node.addChild(document)
+ return true
+ } catch (e: Exception) {
+ error("[DocumentsTree]: Cannot create file, error: " + e.message)
+ }
+ }
+
+ @Synchronized
+ fun createDir(filepath: String, name: String): Boolean {
+ val node = resolvePath(filepath) ?: return false
+ if (!node.isDirectory) return false
+ if (!node.loaded) structTree(node)
+ try {
+ val filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD)
+ if (node.findChild(filename) != null) return true
+ val createdDirectory = FileUtil.createDir(node.uri.toString(), name) ?: return false
+ val document = DocumentsNode(createdDirectory, true)
+ document.parent = node
+ node.addChild(document)
+ return true
+ } catch (e: Exception) {
+ error("[DocumentsTree]: Cannot create file, error: " + e.message)
+ }
+ }
+
+ @Synchronized
+ fun openContentUri(filepath: String, openMode: String): Int {
+ val node = resolvePath(filepath) ?: return -1
+ return FileUtil.openContentUri(node.uri.toString(), openMode)
+ }
+
+ @Synchronized
+ fun getFilename(filepath: String): String {
+ val node = resolvePath(filepath) ?: return ""
+ return node.name
+ }
+
+ @Synchronized
+ fun getFilesName(filepath: String): Array {
+ val node = resolvePath(filepath)
+ if (node == null || !node.isDirectory) {
+ return arrayOfNulls(0)
+ }
+ // If this directory has not been iterated, struct it.
+ if (!node.loaded) structTree(node)
+ return node.getChildNames()
+ }
+
+ @Synchronized
+ fun getFileSize(filepath: String): Long {
+ val node = resolvePath(filepath)
+ return if (node == null || node.isDirectory) {
+ 0
+ } else {
+ FileUtil.getFileSize(node.uri.toString())
+ }
+ }
+
+ @Synchronized
+ fun isDirectory(filepath: String): Boolean {
+ val node = resolvePath(filepath) ?: return false
+ return node.isDirectory
+ }
+
+ @Synchronized
+ fun exists(filepath: String): Boolean {
+ return resolvePath(filepath) != null
+ }
+
+ @Synchronized
+ fun copyFile(
+ sourcePath: String,
+ destinationParentPath: String,
+ destinationFilename: String
+ ): Boolean {
+ val sourceNode = resolvePath(sourcePath) ?: return false
+ val destinationNode = resolvePath(destinationParentPath) ?: return false
+ try {
+ val destinationParent =
+ DocumentFile.fromTreeUri(context, destinationNode.uri!!) ?: return false
+ val filename = URLDecoder.decode(destinationFilename, "UTF-8")
+ val destination = destinationParent.createFile(
+ "application/octet-stream",
+ filename
+ ) ?: return false
+ val document = DocumentsNode()
+ document.uri = destination.uri
+ document.parent = destinationNode
+ document.name = destination.name!!
+ document.isDirectory = destination.isDirectory
+ document.loaded = true
+ val input = context.contentResolver.openInputStream(sourceNode.uri!!)!!
+ val output = context.contentResolver.openOutputStream(destination.uri, "wt")!!
+ val buffer = ByteArray(1024)
+ var len: Int
+ while (input.read(buffer).also { len = it } != -1) {
+ output.write(buffer, 0, len)
+ }
+ input.close()
+ output.flush()
+ output.close()
+ destinationNode.addChild(document)
+ return true
+ } catch (e: Exception) {
+ error("[DocumentsTree]: Cannot copy file, error: " + e.message)
+ }
+ }
+
+ @Synchronized
+ fun renameFile(filepath: String, destinationFilename: String?): Boolean {
+ val node = resolvePath(filepath) ?: return false
+ try {
+ val filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD)
+ DocumentsContract.renameDocument(context.contentResolver, node.uri!!, filename)
+ node.rename(filename)
+ return true
+ } catch (e: Exception) {
+ error("[DocumentsTree]: Cannot rename file, error: " + e.message)
+ }
+ }
+
+ @Synchronized
+ fun deleteDocument(filepath: String): Boolean {
+ val node = resolvePath(filepath) ?: return false
+ try {
+ if (!DocumentsContract.deleteDocument(context.contentResolver, node.uri!!)) {
+ return false
+ }
+ if (node.parent != null) {
+ node.parent!!.removeChild(node)
+ }
+ return true
+ } catch (e: Exception) {
+ error("[DocumentsTree]: Cannot rename file, error: " + e.message)
+ }
+ }
+
+ @Synchronized
+ private fun resolvePath(filepath: String): DocumentsNode? {
+ root ?: return null
+ val tokens = StringTokenizer(filepath, DELIMITER, false)
+ var iterator = root
+ while (tokens.hasMoreTokens()) {
+ val token = tokens.nextToken()
+ if (token.isEmpty()) continue
+ iterator = find(iterator!!, token)
+ if (iterator == null) return null
+ }
+ return iterator
+ }
+
+ @Synchronized
+ private fun find(parent: DocumentsNode, filename: String): DocumentsNode? {
+ if (parent.isDirectory && !parent.loaded) {
+ structTree(parent)
+ }
+ return parent.findChild(filename)
+ }
+
+ /**
+ * Construct current level directory tree
+ *
+ * @param parent parent node of this level
+ */
+ @Synchronized
+ private fun structTree(parent: DocumentsNode) {
+ val documents = FileUtil.listFiles(parent.uri!!)
+ for (document in documents) {
+ val node = DocumentsNode(document)
+ node.parent = parent
+ parent.addChild(node)
+ }
+ parent.loaded = true
+ }
+
+ private class DocumentsNode {
+ @get:Synchronized
+ @set:Synchronized
+ var parent: DocumentsNode? = null
+ val children: MutableMap = ConcurrentHashMap()
+ lateinit var name: String
+
+ @get:Synchronized
+ @set:Synchronized
+ var uri: Uri? = null
+
+ @get:Synchronized
+ @set:Synchronized
+ var loaded = false
+ var isDirectory = false
+
+ constructor()
+ constructor(document: CheapDocument) {
+ name = document.filename
+ uri = document.uri
+ isDirectory = document.isDirectory
+ loaded = !isDirectory
+ }
+
+ constructor(document: DocumentFile, isCreateDir: Boolean) {
+ name = document.name!!
+ uri = document.uri
+ isDirectory = isCreateDir
+ loaded = true
+ }
+
+ @Synchronized
+ fun rename(name: String) {
+ parent ?: return
+ parent!!.removeChild(this)
+ this.name = name
+ parent!!.addChild(this)
+ }
+
+ fun addChild(node: DocumentsNode) {
+ children[node.name.lowercase()] = node
+ }
+
+ fun removeChild(node: DocumentsNode) = children.remove(node.name.lowercase())
+
+ fun findChild(filename: String) = children[filename.lowercase()]
+
+ @Synchronized
+ fun getChildNames(): Array =
+ children.mapNotNull { it.value!!.name }.toTypedArray()
+ }
+
+ companion object {
+ const val DELIMITER = "/"
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
index 0dc2d764d..2b31876b6 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
@@ -6,7 +6,7 @@ import android.preference.PreferenceManager;
import org.citra.citra_emu.CitraApplication;
public class EmulationMenuSettings {
- private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
// These must match what is defined in src/common/settings.h
public static final int LayoutOption_Default = 0;
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
deleted file mode 100644
index 6eb9de33e..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
+++ /dev/null
@@ -1,454 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.ContentResolver;
-import android.content.Context;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import android.provider.DocumentsContract;
-import android.system.Os;
-import android.system.StructStatVfs;
-import android.util.Pair;
-import androidx.annotation.Nullable;
-import androidx.documentfile.provider.DocumentFile;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.URLDecoder;
-import java.util.ArrayList;
-import java.util.List;
-import org.citra.citra_emu.model.CheapDocument;
-
-public class FileUtil {
- static final String PATH_TREE = "tree";
- static final String DECODE_METHOD = "UTF-8";
- static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
- static final String TEXT_PLAIN = "text/plain";
-
- public interface CopyDirListener {
- void onSearchProgress(String directoryName);
- void onCopyProgress(String filename, int progress, int max);
-
- void onComplete();
- }
-
- /**
- * Create a file from directory with filename.
- *
- * @param context Application context
- * @param directory parent path for file.
- * @param filename file display name.
- * @return boolean
- */
- @Nullable
- public static DocumentFile createFile(Context context, String directory, String filename) {
- try {
- Uri directoryUri = Uri.parse(directory);
- DocumentFile parent;
- parent = DocumentFile.fromTreeUri(context, directoryUri);
- if (parent == null) return null;
- filename = URLDecoder.decode(filename, DECODE_METHOD);
- int extensionPosition = filename.lastIndexOf('.');
- String extension = "";
- if (extensionPosition > 0) {
- extension = filename.substring(extensionPosition);
- }
- String mimeType = APPLICATION_OCTET_STREAM;
- if (extension.equals(".txt")) {
- mimeType = TEXT_PLAIN;
- }
- DocumentFile isExist = parent.findFile(filename);
- if (isExist != null) return isExist;
- return parent.createFile(mimeType, filename);
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage());
- }
- return null;
- }
-
- /**
- * Create a directory from directory with filename.
- *
- * @param context Application context
- * @param directory parent path for directory.
- * @param directoryName directory display name.
- * @return boolean
- */
- @Nullable
- public static DocumentFile createDir(Context context, String directory, String directoryName) {
- try {
- Uri directoryUri = Uri.parse(directory);
- DocumentFile parent;
- parent = DocumentFile.fromTreeUri(context, directoryUri);
- if (parent == null) return null;
- directoryName = URLDecoder.decode(directoryName, DECODE_METHOD);
- DocumentFile isExist = parent.findFile(directoryName);
- if (isExist != null) return isExist;
- return parent.createDirectory(directoryName);
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage());
- }
- return null;
- }
-
- /**
- * 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
- */
- public static int openContentUri(Context context, String path, String openmode) {
- try (ParcelFileDescriptor parcelFileDescriptor =
- context.getContentResolver().openFileDescriptor(Uri.parse(path), openmode)) {
- if (parcelFileDescriptor == null) {
- Log.error("[FileUtil]: Cannot get the file descriptor from uri: " + path);
- return -1;
- }
- return parcelFileDescriptor.detachFd();
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot open content uri, error: " + e.getMessage());
- }
- return -1;
- }
-
- /**
- * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
- * This function will be faster than DocumentFile.listFiles
- *
- * @param context Application context
- * @param uri Directory uri.
- * @return CheapDocument lists.
- */
- public static CheapDocument[] listFiles(Context context, Uri uri) {
- final ContentResolver resolver = context.getContentResolver();
- final String[] columns = new String[]{
- DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- DocumentsContract.Document.COLUMN_DISPLAY_NAME,
- DocumentsContract.Document.COLUMN_MIME_TYPE,
- };
- Cursor c = null;
- final List results = new ArrayList<>();
- try {
- String docId;
- if (isRootTreeUri(uri)) {
- docId = DocumentsContract.getTreeDocumentId(uri);
- } else {
- docId = DocumentsContract.getDocumentId(uri);
- }
- final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId);
- c = resolver.query(childrenUri, columns, null, null, null);
- while (c.moveToNext()) {
- final String documentId = c.getString(0);
- final String documentName = c.getString(1);
- final String documentMimeType = c.getString(2);
- final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId);
- CheapDocument document = new CheapDocument(documentName, documentMimeType, documentUri);
- results.add(document);
- }
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot list file error: " + e.getMessage());
- } finally {
- closeQuietly(c);
- }
- return results.toArray(new CheapDocument[0]);
- }
-
- /**
- * Check whether given path exists.
- *
- * @param path Native content uri path
- * @return bool
- */
- public static boolean Exists(Context context, String path) {
- Cursor c = null;
- try {
- Uri mUri = Uri.parse(path);
- final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DOCUMENT_ID};
- c = context.getContentResolver().query(mUri, columns, null, null, null);
- return c.getCount() > 0;
- } catch (Exception e) {
- Log.info("[FileUtil] Cannot find file from given path, error: " + e.getMessage());
- } finally {
- closeQuietly(c);
- }
- return false;
- }
-
- /**
- * Check whether given path is a directory
- *
- * @param path content uri path
- * @return bool
- */
- public static boolean isDirectory(Context context, String path) {
- final ContentResolver resolver = context.getContentResolver();
- final String[] columns = new String[] {DocumentsContract.Document.COLUMN_MIME_TYPE};
- boolean isDirectory = false;
- Cursor c = null;
- try {
- Uri mUri = Uri.parse(path);
- c = resolver.query(mUri, columns, null, null, null);
- c.moveToNext();
- final String mimeType = c.getString(0);
- isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR);
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot list files, error: " + e.getMessage());
- } finally {
- closeQuietly(c);
- }
- return isDirectory;
- }
-
- /**
- * Get file display name from given path
- *
- * @param path content uri path
- * @return String display name
- */
- public static String getFilename(Context context, String path) {
- final ContentResolver resolver = context.getContentResolver();
- final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
- String filename = "";
- Cursor c = null;
- try {
- Uri mUri = Uri.parse(path);
- c = resolver.query(mUri, columns, null, null, null);
- c.moveToNext();
- filename = c.getString(0);
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage());
- } finally {
- closeQuietly(c);
- }
- return filename;
- }
-
- public static String[] getFilesName(Context context, String path) {
- Uri uri = Uri.parse(path);
- List files = new ArrayList<>();
- for (CheapDocument file : FileUtil.listFiles(context, uri)) {
- files.add(file.getFilename());
- }
- return files.toArray(new String[0]);
- }
-
- /**
- * Get file size from given path.
- *
- * @param path content uri path
- * @return long file size
- */
- public static long getFileSize(Context context, String path) {
- final ContentResolver resolver = context.getContentResolver();
- final String[] columns = new String[] {DocumentsContract.Document.COLUMN_SIZE};
- long size = 0;
- Cursor c = null;
- try {
- Uri mUri = Uri.parse(path);
- c = resolver.query(mUri, columns, null, null, null);
- c.moveToNext();
- size = c.getLong(0);
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage());
- } finally {
- closeQuietly(c);
- }
- return size;
- }
-
- public static boolean copyFile(Context context, String sourcePath, String destinationParentPath, String destinationFilename) {
- try {
- Uri sourceUri = Uri.parse(sourcePath);
- Uri destinationUri = Uri.parse(destinationParentPath);
- DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationUri);
- if (destinationParent == null) return false;
- String filename = URLDecoder.decode(destinationFilename, "UTF-8");
- DocumentFile destination = destinationParent.findFile(filename);
- if (destination == null) {
- destination = destinationParent.createFile("application/octet-stream", filename);
- }
- if (destination == null) return false;
- InputStream input = context.getContentResolver().openInputStream(sourceUri);
- OutputStream output = context.getContentResolver().openOutputStream(destination.getUri(), "wt");
- byte[] buffer = new byte[1024];
- int len;
- while ((len = input.read(buffer)) != -1) {
- output.write(buffer, 0, len);
- }
- input.close();
- output.flush();
- output.close();
- return true;
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot copy file, error: " + e.getMessage());
- }
- return false;
- }
-
- public static void copyDir(Context context, String sourcePath, String destinationPath,
- CopyDirListener listener) {
- try {
- Uri sourceUri = Uri.parse(sourcePath);
- Uri destinationUri = Uri.parse(destinationPath);
- final List> files = new ArrayList<>();
- final List> dirs = new ArrayList<>();
- dirs.add(new Pair<>(sourceUri, destinationUri));
- // Searching all files which need to be copied and struct the directory in destination.
- while (!dirs.isEmpty()) {
- DocumentFile fromDir = DocumentFile.fromTreeUri(context, dirs.get(0).first);
- DocumentFile toDir = DocumentFile.fromTreeUri(context, dirs.get(0).second);
- if (fromDir == null || toDir == null)
- continue;
- Uri fromUri = fromDir.getUri();
- if (listener != null) {
- listener.onSearchProgress(fromUri.getPath());
- }
- CheapDocument[] documents = FileUtil.listFiles(context, fromUri);
- for (CheapDocument document : documents) {
- String filename = document.getFilename();
- if (document.isDirectory()) {
- DocumentFile target = toDir.findFile(filename);
- if (target == null || !target.exists()) {
- target = toDir.createDirectory(filename);
- }
- if (target == null)
- continue;
- dirs.add(new Pair<>(document.getUri(), target.getUri()));
- } else {
- DocumentFile target = toDir.findFile(filename);
- if (target == null || !target.exists()) {
- target =
- toDir.createFile(document.getMimeType(), document.getFilename());
- }
- if (target == null)
- continue;
- files.add(new Pair<>(document, target));
- }
- }
-
- dirs.remove(0);
- }
-
- int total = files.size();
- int progress = 0;
- for (Pair file : files) {
- DocumentFile to = file.second;
- Uri toUri = to.getUri();
- String toPath = toUri.getPath();
- DocumentFile toParent = to.getParentFile();
- if (toParent == null)
- continue;
- FileUtil.copyFile(context, file.first.getUri().toString(),
- toParent.getUri().toString(), to.getName());
- progress++;
- if (listener != null) {
- listener.onCopyProgress(toPath, progress, total);
- }
- }
- if (listener != null) {
- listener.onComplete();
- }
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot copy directory, error: " + e.getMessage());
- }
- }
-
- public static boolean renameFile(Context context, String path, String destinationFilename) {
- try {
- Uri uri = Uri.parse(path);
- DocumentsContract.renameDocument(context.getContentResolver(), uri, destinationFilename);
- return true;
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot rename file, error: " + e.getMessage());
- }
- return false;
- }
-
- public static boolean deleteDocument(Context context, String path) {
- try {
- Uri uri = Uri.parse(path);
- DocumentsContract.deleteDocument(context.getContentResolver(), uri);
- return true;
- } catch (Exception e) {
- Log.error("[FileUtil]: Cannot delete document, error: " + e.getMessage());
- }
- return false;
- }
-
- public static byte[] getBytesFromFile(Context context, DocumentFile file) throws IOException {
- final Uri uri = file.getUri();
- final long length = FileUtil.getFileSize(context, uri.toString());
-
- // You cannot create an array using a long type.
- if (length > Integer.MAX_VALUE) {
- // File is too large
- throw new IOException("File is too large!");
- }
-
- byte[] bytes = new byte[(int) length];
-
- int offset = 0;
- int numRead;
-
- try (InputStream is = context.getContentResolver().openInputStream(uri)) {
- while (offset < bytes.length &&
- (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
- offset += numRead;
- }
- }
-
- // Ensure all the bytes have been read in
- if (offset < bytes.length) {
- throw new IOException("Could not completely read file " + file.getName());
- }
-
- return bytes;
- }
-
- public static boolean isRootTreeUri(Uri uri) {
- final List paths = uri.getPathSegments();
- return paths.size() == 2 && PATH_TREE.equals(paths.get(0));
- }
-
- public static boolean isNativePath(String path) {
- try {
- return path.charAt(0) == '/';
- } catch (StringIndexOutOfBoundsException e) {
- Log.error("[FileUtil] Cannot determine the string is native path or not.");
- }
- return false;
- }
-
- public static double getFreeSpace(Context context, Uri uri) {
- try {
- Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree(
- uri, DocumentsContract.getTreeDocumentId(uri));
- ParcelFileDescriptor pfd =
- context.getContentResolver().openFileDescriptor(docTreeUri, "r");
- assert pfd != null;
- StructStatVfs stats = Os.fstatvfs(pfd.getFileDescriptor());
- double spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024;
- pfd.close();
- return spaceInGigaBytes;
- } catch (Exception e) {
- Log.error("[FileUtil] Cannot get storage size.");
- }
-
- return 0;
- }
-
- public static void closeQuietly(AutoCloseable closeable) {
- if (closeable != null) {
- try {
- closeable.close();
- } catch (RuntimeException rethrown) {
- throw rethrown;
- } catch (Exception ignored) {
- }
- }
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt
new file mode 100644
index 000000000..402a23857
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt
@@ -0,0 +1,598 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import okio.ByteString.Companion.readByteString
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.system.Os
+import android.util.Pair
+import androidx.documentfile.provider.DocumentFile
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.model.CheapDocument
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+object FileUtil {
+ const val PATH_TREE = "tree"
+ const val DECODE_METHOD = "UTF-8"
+ const val APPLICATION_OCTET_STREAM = "application/octet-stream"
+ const val TEXT_PLAIN = "text/plain"
+
+ val context: Context get() = CitraApplication.appContext
+
+ /**
+ * Create a file from directory with filename.
+ *
+ * @param directory parent path for file.
+ * @param filename file display name.
+ * @return boolean
+ */
+ @JvmStatic
+ fun createFile(directory: String, filename: String): DocumentFile? {
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent = DocumentFile.fromTreeUri(context, directoryUri)
+ ?: return null
+ val decodedFilename = URLDecoder.decode(filename, DECODE_METHOD)
+ val extensionPosition = decodedFilename.lastIndexOf('.')
+
+ var extension = ""
+ if (extensionPosition > 0) {
+ extension = decodedFilename.substring(extensionPosition)
+ }
+
+ var mimeType = APPLICATION_OCTET_STREAM
+ if (extension == ".txt") {
+ mimeType = TEXT_PLAIN
+ }
+
+ val exists = parent.findFile(decodedFilename)
+ return exists ?: parent.createFile(mimeType, decodedFilename)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ return null
+ }
+ }
+
+ /**
+ * Create a directory from directory with filename.
+ *
+ * @param directory parent path for directory.
+ * @param directoryName directory display name.
+ * @return boolean
+ */
+ @JvmStatic
+ fun createDir(directory: String, directoryName: String): DocumentFile? {
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent: DocumentFile =
+ DocumentFile.fromTreeUri(context, directoryUri)
+ ?: return null
+ val decodedDirectoryName = URLDecoder.decode(directoryName, DECODE_METHOD)
+ val exists = parent.findFile(decodedDirectoryName)
+ return exists ?: parent.createDirectory(decodedDirectoryName)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ return null
+ }
+ }
+
+ /**
+ * Open content uri and return file descriptor to JNI.
+ *
+ * @param path Native content uri path
+ * @param openMode will be one of "r", "r", "rw", "wa", "rwa"
+ * @return file descriptor
+ */
+ @JvmStatic
+ fun openContentUri(path: String, openMode: String): Int {
+ try {
+ context
+ .contentResolver
+ .openFileDescriptor(Uri.parse(path), openMode)
+ .use { parcelFileDescriptor ->
+ if (parcelFileDescriptor == null) {
+ Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
+ return -1
+ }
+ return parcelFileDescriptor.detachFd()
+ }
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
+ return -1
+ }
+ }
+
+ /**
+ * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
+ * This function will be faster than DocumentFile.listFiles
+ *
+ * @param uri Directory uri.
+ * @return CheapDocument lists.
+ */
+ @JvmStatic
+ fun listFiles(uri: Uri): Array {
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE
+ )
+ var c: Cursor? = null
+ val results: MutableList = ArrayList()
+ try {
+ val docId = if (isRootTreeUri(uri)) {
+ DocumentsContract.getTreeDocumentId(uri)
+ } else {
+ DocumentsContract.getDocumentId(uri)
+ }
+
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
+ c = context.contentResolver.query(childrenUri, columns, null, null, null)
+ while (c!!.moveToNext()) {
+ val documentId = c.getString(0)
+ val documentName = c.getString(1)
+ val documentMimeType = c.getString(2)
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ val document = CheapDocument(documentName, documentMimeType, documentUri)
+ results.add(document)
+ }
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list file error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return results.toTypedArray()
+ }
+
+ /**
+ * Check whether given path exists.
+ *
+ * @param path Native content uri path
+ * @return bool
+ */
+ @JvmStatic
+ fun exists(path: String): Boolean {
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ return c!!.count > 0
+ } catch (e: Exception) {
+ Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return false
+ }
+
+ /**
+ * Check whether given path is a directory
+ *
+ * @param path content uri path
+ * @return bool
+ */
+ @JvmStatic
+ fun isDirectory(path: String): Boolean {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE)
+ var isDirectory = false
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ c = context.contentResolver.query(uri, columns, null, null, null)
+ c!!.moveToNext()
+ val mimeType = c.getString(0)
+ isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list files, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return isDirectory
+ }
+
+ /**
+ * Get file display name from given path
+ *
+ * @param uri content uri
+ * @return String display name
+ */
+ @JvmStatic
+ fun getFilename(uri: Uri): String {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+ var filename = ""
+ var c: Cursor? = null
+ try {
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ c!!.moveToNext()
+ filename = c.getString(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file name, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return filename
+ }
+
+ @JvmStatic
+ fun getFilesName(path: String): Array {
+ val uri = Uri.parse(path)
+ val files: MutableList = ArrayList()
+ listFiles(uri).forEach { files.add(it.filename) }
+ return files.toTypedArray()
+ }
+
+ /**
+ * Get file size from given path.
+ *
+ * @param path content uri path
+ * @return long file size
+ */
+ @JvmStatic
+ fun getFileSize(path: String): Long {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_SIZE)
+ var size: Long = 0
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ c!!.moveToNext()
+ size = c.getLong(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return size
+ }
+
+ @JvmStatic
+ fun copyFile(
+ sourceUri: Uri,
+ destinationUri: Uri,
+ destinationFilename: String
+ ): Boolean {
+ try {
+ val destinationParent =
+ DocumentFile.fromTreeUri(context, destinationUri) ?: return false
+ val filename = URLDecoder.decode(destinationFilename, "UTF-8")
+ var destination = destinationParent.findFile(filename)
+ if (destination == null) {
+ destination =
+ destinationParent.createFile("application/octet-stream", filename)
+ }
+ if (destination == null) {
+ return false
+ }
+
+ val input = context.contentResolver.openInputStream(sourceUri)
+ val output = context.contentResolver.openOutputStream(destination.uri, "wt")
+ val buffer = ByteArray(1024)
+ var len: Int
+ while (input!!.read(buffer).also { len = it } != -1) {
+ output!!.write(buffer, 0, len)
+ }
+ input.close()
+ output?.flush()
+ output?.close()
+ return true
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot copy file, error: " + e.message)
+ }
+ return false
+ }
+
+ fun copyUriToInternalStorage(
+ sourceUri: Uri?,
+ destinationParentPath: String,
+ destinationFilename: String
+ ): Boolean {
+ var input: InputStream? = null
+ var output: FileOutputStream? = null
+ 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)
+ }
+ }
+ if (output != null) {
+ try {
+ output.close()
+ } catch (e: IOException) {
+ Log.error("[FileUtil]: Cannot close output file, error: " + e.message)
+ }
+ }
+ }
+ return false
+ }
+
+ fun copyDir(
+ sourcePath: String,
+ destinationPath: String,
+ listener: CopyDirListener?
+ ) {
+ try {
+ val sourceUri = Uri.parse(sourcePath)
+ val destinationUri = Uri.parse(destinationPath)
+ val files: MutableList> = ArrayList()
+ val dirs: MutableList> = ArrayList()
+ dirs.add(Pair(sourceUri, destinationUri))
+
+ // Searching all files which need to be copied and struct the directory in destination
+ while (dirs.isNotEmpty()) {
+ val fromDir = DocumentFile.fromTreeUri(context, dirs[0].first)
+ val toDir = DocumentFile.fromTreeUri(context, dirs[0].second)
+ if (fromDir == null || toDir == null) {
+ continue
+ }
+
+ val fromUri = fromDir.uri
+ listener?.onSearchProgress(fromUri.path ?: "")
+ val documents = listFiles(fromUri)
+ for (document in documents) {
+ // Prevent infinite recursion if the source dir is being copied to a dir within itself
+ if (document.filename == toDir.name) {
+ continue
+ }
+
+ val filename = document.filename
+ if (document.isDirectory) {
+ var target = toDir.findFile(filename)
+ if (target == null || !target.exists()) {
+ target = toDir.createDirectory(filename)
+ }
+ if (target == null) {
+ continue
+ }
+
+ dirs.add(Pair(document.uri, target.uri))
+ } else {
+ var target = toDir.findFile(filename)
+ if (target == null || !target.exists()) {
+ target = toDir.createFile(document.mimeType, document.filename)
+ }
+ if (target == null) {
+ continue
+ }
+
+ files.add(Pair(document, target))
+ }
+ }
+ dirs.removeAt(0)
+ }
+
+ var progress = 0
+ for (file in files) {
+ val to = file.second
+ val toUri = to.uri
+ val toPath = toUri.path ?: ""
+ val toParent = to.parentFile ?: continue
+ copyFile(file.first.uri, toParent.uri, to.name!!)
+ progress++
+ listener?.onCopyProgress(toPath, progress, files.size)
+ }
+ listener?.onComplete()
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot copy directory, error: " + e.message)
+ }
+ }
+
+ @JvmStatic
+ fun renameFile(path: String, destinationFilename: String): Boolean {
+ try {
+ val uri = Uri.parse(path)
+ DocumentsContract.renameDocument(context.contentResolver, uri, destinationFilename)
+ return true
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot rename file, error: " + e.message)
+ }
+ return false
+ }
+
+ @JvmStatic
+ fun deleteDocument(path: String): Boolean {
+ try {
+ val uri = Uri.parse(path)
+ DocumentsContract.deleteDocument(context.contentResolver, uri)
+ return true
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot delete document, error: " + e.message)
+ }
+ return false
+ }
+
+ @Throws(IOException::class)
+ fun getBytesFromFile(file: DocumentFile): ByteArray {
+ val uri = file.uri
+ val length = getFileSize(uri.toString())
+
+ // You cannot create an array using a long type.
+ if (length > Int.MAX_VALUE) {
+ // File is too large
+ throw IOException("File is too large!")
+ }
+
+ val bytes = ByteArray(length.toInt())
+
+ var offset = 0
+ var numRead = 0
+ context.contentResolver.openInputStream(uri).use { inputStream ->
+ while (offset < bytes.size &&
+ inputStream!!.read(bytes, offset, bytes.size - offset).also { numRead = it } >= 0
+ ) {
+ offset += numRead
+ }
+ }
+
+ // Ensure all the bytes have been read in
+ if (offset < bytes.size) {
+ throw IOException("Could not completely read file " + file.name)
+ }
+
+ return bytes
+ }
+
+ /**
+ * Extracts the given zip file into the given directory.
+ */
+ @Throws(SecurityException::class)
+ fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) {
+ ZipInputStream(zipStream).use { zis ->
+ var entry: ZipEntry? = zis.nextEntry
+ while (entry != null) {
+ 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) {
+ newFile.outputStream().use { fos -> zis.copyTo(fos) }
+ }
+ entry = zis.nextEntry
+ }
+ }
+ }
+
+ fun copyToExternalStorage(
+ sourceFile: Uri,
+ destinationDir: DocumentFile
+ ): DocumentFile? {
+ val filename = getFilename(sourceFile)
+ val destinationFile = destinationDir.createFile("application/zip", filename)!!
+ destinationFile.outputStream().use { os ->
+ sourceFile.inputStream().use { it.copyTo(os) }
+ }
+ return destinationDir.findFile(filename)
+ }
+
+ fun isRootTreeUri(uri: Uri): Boolean {
+ val paths = uri.pathSegments
+ return paths.size == 2 && PATH_TREE == paths[0]
+ }
+
+ @JvmStatic
+ fun isNativePath(path: String): Boolean =
+ try {
+ path[0] == '/'
+ } catch (e: StringIndexOutOfBoundsException) {
+ Log.error("[FileUtil] Cannot determine the string is native path or not.")
+ false
+ }
+
+ fun getFreeSpace(context: Context, uri: Uri?): Double =
+ try {
+ val docTreeUri = DocumentsContract.buildDocumentUriUsingTree(
+ uri,
+ DocumentsContract.getTreeDocumentId(uri)
+ )
+ val pfd = context.contentResolver.openFileDescriptor(docTreeUri, "r")!!
+ val stats = Os.fstatvfs(pfd.fileDescriptor)
+ val spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024
+ pfd.close()
+ spaceInGigaBytes
+ } catch (e: Exception) {
+ Log.error("[FileUtil] Cannot get storage size.")
+ 0.0
+ }
+
+ fun closeQuietly(closeable: AutoCloseable?) {
+ if (closeable != null) {
+ try {
+ closeable.close()
+ } catch (rethrown: RuntimeException) {
+ throw rethrown
+ } catch (ignored: Exception) {
+ }
+ }
+ }
+
+ fun getExtension(uri: Uri): String {
+ val fileName = getFilename(uri)
+ 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, length: Long = 0L): String =
+ if (length == 0L) {
+ String(stream.readBytes(), StandardCharsets.UTF_8)
+ } else {
+ String(stream.readByteString(length.toInt()).toByteArray(), StandardCharsets.UTF_8)
+ }
+
+ fun DocumentFile.inputStream(): InputStream =
+ CitraApplication.appContext.contentResolver.openInputStream(uri)!!
+
+ fun DocumentFile.outputStream(): OutputStream =
+ CitraApplication.appContext.contentResolver.openOutputStream(uri)!!
+
+ fun Uri.inputStream(): InputStream =
+ CitraApplication.appContext.contentResolver.openInputStream(this)!!
+
+ fun Uri.outputStream(): OutputStream =
+ CitraApplication.appContext.contentResolver.openOutputStream(this)!!
+
+ fun Uri.asDocumentFile(): DocumentFile? =
+ DocumentFile.fromSingleUri(CitraApplication.appContext, this)
+
+ interface CopyDirListener {
+ fun onSearchProgress(directoryName: String)
+ fun onCopyProgress(filename: String, progress: Int, max: Int)
+ fun onComplete()
+ }
+}
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
new file mode 100644
index 000000000..9ad2e88ff
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt
@@ -0,0 +1,107 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.SharedPreferences
+import android.net.Uri
+import androidx.preference.PreferenceManager
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.NativeLibrary
+import org.citra.citra_emu.model.CheapDocument
+import org.citra.citra_emu.model.Game
+import org.citra.citra_emu.model.GameInfo
+import java.io.IOException
+
+object GameHelper {
+ const val KEY_GAME_PATH = "game_path"
+ const val KEY_GAMES = "Games"
+
+ private lateinit var preferences: SharedPreferences
+
+ fun getGames(): List {
+ val games = mutableListOf()
+ val context = CitraApplication.appContext
+ preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val gamesDir = preferences.getString(KEY_GAME_PATH, "")
+ val gamesUri = Uri.parse(gamesDir)
+
+ addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3)
+ NativeLibrary.getInstalledGamePaths().forEach {
+ games.add(getGame(Uri.parse(it), isInstalled = true, addedToLibrary = true))
+ }
+
+ // Cache list of games found on disk
+ val serializedGames = mutableSetOf()
+ games.forEach {
+ serializedGames.add(Json.encodeToString(it))
+ }
+ preferences.edit()
+ .remove(KEY_GAMES)
+ .putStringSet(KEY_GAMES, serializedGames)
+ .apply()
+
+ return games.toList()
+ }
+
+ private fun addGamesRecursive(
+ games: MutableList,
+ files: Array,
+ depth: Int
+ ) {
+ if (depth <= 0) {
+ return
+ }
+
+ files.forEach {
+ if (it.isDirectory) {
+ addGamesRecursive(games, FileUtil.listFiles(it.uri), depth - 1)
+ } else {
+ if (Game.allExtensions.contains(FileUtil.getExtension(it.uri))) {
+ games.add(getGame(it.uri, isInstalled = false, addedToLibrary = true))
+ }
+ }
+ }
+ }
+
+ fun getGame(uri: Uri, isInstalled: Boolean, addedToLibrary: Boolean): Game {
+ val filePath = uri.toString()
+ val gameInfo: GameInfo? = try {
+ GameInfo(filePath)
+ } catch (e: IOException) {
+ null
+ }
+
+ val newGame = Game(
+ (gameInfo?.getTitle() ?: FileUtil.getFilename(uri)).replace("[\\t\\n\\r]+".toRegex(), " "),
+ filePath.replace("\n", " "),
+ filePath,
+ NativeLibrary.getTitleId(filePath),
+ gameInfo?.getCompany() ?: "",
+ gameInfo?.getRegions() ?: "Invalid region",
+ isInstalled,
+ NativeLibrary.getIsSystemTitle(filePath),
+ gameInfo?.getIsVisibleSystemTitle() ?: false,
+ gameInfo?.getIcon(),
+ if (FileUtil.isNativePath(filePath)) {
+ CitraApplication.documentsTree.getFilename(filePath)
+ } else {
+ FileUtil.getFilename(Uri.parse(filePath))
+ }
+ )
+
+ if (addedToLibrary) {
+ val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
+ if (addedTime == 0L) {
+ preferences.edit()
+ .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
+ .apply()
+ }
+ }
+
+ return newGame
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
deleted file mode 100644
index 7057c07ad..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.graphics.Bitmap;
-
-import com.squareup.picasso.Picasso;
-import com.squareup.picasso.Request;
-import com.squareup.picasso.RequestHandler;
-
-import org.citra.citra_emu.NativeLibrary;
-import org.citra.citra_emu.model.GameInfo;
-
-import java.io.IOException;
-import java.nio.IntBuffer;
-
-public class GameIconRequestHandler extends RequestHandler {
- @Override
- public boolean canHandleRequest(Request data) {
- return "content".equals(data.uri.getScheme()) || data.uri.getScheme() == null;
- }
-
- @Override
- public Result load(Request request, int networkPolicy) {
- int[] vector;
- try {
- String url = request.uri.toString();
- vector = new GameInfo(url).getIcon();
- } catch (IOException e) {
- vector = null;
- }
-
- Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565);
- bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector));
- return new Result(bitmap, Picasso.LoadedFrom.DISK);
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt
new file mode 100644
index 000000000..6ba803ca8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt
@@ -0,0 +1,79 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.graphics.Bitmap
+import android.widget.ImageView
+import androidx.core.graphics.drawable.toDrawable
+import androidx.fragment.app.FragmentActivity
+import coil.ImageLoader
+import coil.decode.DataSource
+import coil.fetch.DrawableResult
+import coil.fetch.FetchResult
+import coil.fetch.Fetcher
+import coil.key.Keyer
+import coil.memory.MemoryCache
+import coil.request.ImageRequest
+import coil.request.Options
+import coil.transform.RoundedCornersTransformation
+import org.citra.citra_emu.R
+import org.citra.citra_emu.model.Game
+import java.nio.IntBuffer
+
+class GameIconFetcher(
+ private val game: Game,
+ private val options: Options
+) : Fetcher {
+ override suspend fun fetch(): FetchResult {
+ return DrawableResult(
+ drawable = getGameIcon(game.icon)!!.toDrawable(options.context.resources),
+ isSampled = false,
+ dataSource = DataSource.DISK
+ )
+ }
+
+ private fun getGameIcon(vector: IntArray?): Bitmap? {
+ val bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565)
+ bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector))
+ return bitmap
+ }
+
+ class Factory : Fetcher.Factory {
+ override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher =
+ GameIconFetcher(data, options)
+ }
+}
+
+class GameIconKeyer : Keyer {
+ override fun key(data: Game, options: Options): String = data.path
+}
+
+object GameIconUtils {
+ fun loadGameIcon(activity: FragmentActivity, game: Game, imageView: ImageView) {
+ val imageLoader = ImageLoader.Builder(activity)
+ .components {
+ add(GameIconKeyer())
+ add(GameIconFetcher.Factory())
+ }
+ .memoryCache {
+ MemoryCache.Builder(activity)
+ .maxSizePercent(0.25)
+ .build()
+ }
+ .build()
+
+ val request = ImageRequest.Builder(activity)
+ .data(game)
+ .target(imageView)
+ .error(R.drawable.no_icon)
+ .transformations(
+ RoundedCornersTransformation(
+ activity.resources.getDimensionPixelSize(R.dimen.spacing_med).toFloat()
+ )
+ )
+ .build()
+ imageLoader.enqueue(request)
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt
new file mode 100644
index 000000000..8ed7cd2e1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt
@@ -0,0 +1,237 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.net.Uri
+import android.os.Build
+import androidx.documentfile.provider.DocumentFile
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.NativeLibrary
+import org.citra.citra_emu.utils.FileUtil.asDocumentFile
+import org.citra.citra_emu.utils.FileUtil.inputStream
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.lang.IllegalStateException
+import java.util.zip.ZipEntry
+import java.util.zip.ZipException
+import java.util.zip.ZipInputStream
+
+object GpuDriverHelper {
+ private const val META_JSON_FILENAME = "meta.json"
+ private var fileRedirectionPath: String? = null
+ var driverInstallationPath: String? = null
+ private var hookLibPath: String? = null
+
+ val driverStoragePath: DocumentFile
+ get() {
+ // Bypass directory initialization checks
+ val root = DocumentFile.fromTreeUri(
+ CitraApplication.appContext,
+ Uri.parse(DirectoryInitialization.userPath)
+ )!!
+ var driverDirectory = root.findFile("gpu_drivers")
+ if (driverDirectory == null) {
+ driverDirectory = FileUtil.createDir(root.uri.toString(), "gpu_drivers")
+ }
+ return driverDirectory!!
+ }
+
+ fun initializeDriverParameters() {
+ try {
+ // Initialize the file redirection directory.
+ fileRedirectionPath =
+ DirectoryInitialization.internalUserPath + "/gpu/vk_file_redirect/"
+
+ // Initialize the driver installation directory.
+ driverInstallationPath = CitraApplication.appContext
+ .filesDir.canonicalPath + "/gpu_driver/"
+ } catch (e: IOException) {
+ throw RuntimeException(e)
+ }
+
+ // Initialize directories.
+ initializeDirectories()
+
+ // Initialize hook libraries directory.
+ hookLibPath = CitraApplication.appContext.applicationInfo.nativeLibraryDir + "/"
+
+ // Initialize GPU driver.
+ NativeLibrary.initializeGpuDriver(
+ hookLibPath,
+ driverInstallationPath,
+ customDriverData.libraryName,
+ fileRedirectionPath
+ )
+ }
+
+ fun getDrivers(): MutableList> {
+ val driverZips = driverStoragePath.listFiles()
+ val drivers: MutableList> =
+ driverZips
+ .mapNotNull {
+ val metadata = getMetadataFromZip(it.inputStream())
+ metadata.name?.let { _ -> Pair(it.uri, metadata) }
+ }
+ .sortedByDescending { it: Pair -> it.second.name }
+ .distinct()
+ .toMutableList()
+
+ // TODO: Get system driver information
+ drivers.add(0, Pair(Uri.EMPTY, GpuDriverMetadata()))
+ return drivers
+ }
+
+ fun installDefaultDriver() {
+ // Removing the installed driver will result in the backend using the default system driver.
+ File(driverInstallationPath!!).deleteRecursively()
+ initializeDriverParameters()
+ }
+
+ fun copyDriverToExternalStorage(driverUri: Uri): DocumentFile? {
+ // Ensure we have directories.
+ initializeDirectories()
+
+ // Copy the zip file URI to user data
+ val copiedFile =
+ FileUtil.copyToExternalStorage(driverUri, driverStoragePath) ?: return null
+
+ // Validate driver
+ val metadata = getMetadataFromZip(copiedFile.inputStream())
+ if (metadata.name == null) {
+ copiedFile.delete()
+ return null
+ }
+
+ if (metadata.minApi > Build.VERSION.SDK_INT) {
+ copiedFile.delete()
+ return null
+ }
+ return copiedFile
+ }
+
+ /**
+ * 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 installCustomDriverComplete(driverUri: Uri): Boolean {
+ // Revert to system default in the event the specified driver is bad.
+ installDefaultDriver()
+
+ // Ensure we have directories.
+ initializeDirectories()
+
+ // Copy the zip file URI to user data
+ val copiedFile =
+ FileUtil.copyToExternalStorage(driverUri, driverStoragePath) ?: return false
+
+ // Validate driver
+ val metadata = getMetadataFromZip(copiedFile.inputStream())
+ if (metadata.name == null) {
+ copiedFile.delete()
+ return false
+ }
+
+ if (metadata.minApi > Build.VERSION.SDK_INT) {
+ copiedFile.delete()
+ return false
+ }
+
+ // Unzip the driver.
+ try {
+ FileUtil.unzipToInternalStorage(
+ BufferedInputStream(copiedFile.inputStream()),
+ File(driverInstallationPath!!)
+ )
+ } catch (e: SecurityException) {
+ return false
+ }
+
+ // Initialize the driver parameters.
+ initializeDriverParameters()
+
+ return true
+ }
+
+ /**
+ * Unzips driver into private installation directory
+ */
+ fun installCustomDriverPartial(driver: Uri): Boolean {
+ // Revert to system default in the event the specified driver is bad.
+ installDefaultDriver()
+
+ // Ensure we have directories.
+ initializeDirectories()
+
+ // Validate driver
+ val metadata = getMetadataFromZip(driver.inputStream())
+ if (metadata.name == null) {
+ driver.asDocumentFile()?.delete()
+ return false
+ }
+
+ // Unzip the driver to the private installation directory
+ try {
+ FileUtil.unzipToInternalStorage(
+ BufferedInputStream(driver.inputStream()),
+ File(driverInstallationPath!!)
+ )
+ } catch (e: SecurityException) {
+ return false
+ }
+
+ // 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: InputStream): GpuDriverMetadata {
+ try {
+ ZipInputStream(driver).use { zis ->
+ var entry: ZipEntry? = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory && entry.name.lowercase().contains(".json")) {
+ val size = if (entry.size == -1L) 0L else entry.size
+ return GpuDriverMetadata(zis, size)
+ }
+ entry = zis.nextEntry
+ }
+ }
+ } 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()) {
+ fileRedirectionDir.mkdirs()
+ }
+ // Ensure the driver installation directory exists.
+ val driverInstallationDir = File(driverInstallationPath!!)
+ if (!driverInstallationDir.exists()) {
+ driverInstallationDir.mkdirs()
+ }
+ // Ensure the driver storage directory exists
+ if (!driverStoragePath.exists()) {
+ throw IllegalStateException("Driver storage directory couldn't be created!")
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt
new file mode 100644
index 000000000..2da0ccf92
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt
@@ -0,0 +1,120 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import java.io.IOException
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.File
+import java.io.InputStream
+
+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
+ }
+
+ try {
+ val json = JSONObject(FileUtil.getStringFromFile(metadataFile))
+ 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.
+ }
+ }
+
+ /**
+ * 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, size))
+ 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/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
index 070d01eb1..096332422 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
@@ -8,6 +8,9 @@ import org.citra.citra_emu.BuildConfig;
* levels in release builds.
*/
public final class Log {
+ // Tracks whether we should share the old log or the current log
+ public static boolean gameLaunched = false;
+
private static final String TAG = "Citra Frontend";
private Log() {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
deleted file mode 100644
index 6cbe19b76..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.Uri;
-import android.preference.PreferenceManager;
-
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.annotation.Nullable;
-import androidx.documentfile.provider.DocumentFile;
-import androidx.fragment.app.FragmentActivity;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-
-public class PermissionsHandler {
- public static final String CITRA_DIRECTORY = "CITRA_DIRECTORY";
- public static final SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
-
- // We use permissions acceptance as an indicator if this is a first boot for the user.
- public static boolean isFirstBoot(FragmentActivity activity) {
- return !hasWriteAccess(activity.getApplicationContext());
- }
-
- public static boolean checkWritePermission(FragmentActivity activity,
- ActivityResultLauncher launcher) {
- if (isFirstBoot(activity)) {
- launcher.launch(null);
- return false;
- }
-
- return true;
- }
-
- public static boolean hasWriteAccess(Context context) {
- try {
- Uri uri = getCitraDirectory();
- if (uri == null)
- return false;
- int takeFlags = (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- context.getContentResolver().takePersistableUriPermission(uri, takeFlags);
- DocumentFile root = DocumentFile.fromTreeUri(context, uri);
- if (root != null && root.exists()) return true;
- context.getContentResolver().releasePersistableUriPermission(uri, takeFlags);
- } catch (Exception e) {
- Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.getMessage());
- }
- return false;
- }
-
- @Nullable
- public static Uri getCitraDirectory() {
- String directoryString = mPreferences.getString(CITRA_DIRECTORY, "");
- if (directoryString.isEmpty()) {
- return null;
- }
- return Uri.parse(directoryString);
- }
-
- public static boolean setCitraDirectory(String uriString) {
- return mPreferences.edit().putString(CITRA_DIRECTORY, uriString).commit();
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt
new file mode 100644
index 000000000..913780964
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt
@@ -0,0 +1,50 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
+import androidx.preference.PreferenceManager
+import androidx.documentfile.provider.DocumentFile
+import org.citra.citra_emu.CitraApplication
+
+object PermissionsHandler {
+ const val CITRA_DIRECTORY = "CITRA_DIRECTORY"
+ val preferences: SharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ fun hasWriteAccess(context: Context): Boolean {
+ try {
+ if (citraDirectory.toString().isEmpty()) {
+ return false
+ }
+
+ val uri = citraDirectory
+ val takeFlags =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ context.contentResolver.takePersistableUriPermission(uri, takeFlags)
+ val root = DocumentFile.fromTreeUri(context, uri)
+ if (root != null && root.exists()) {
+ return true
+ }
+
+ context.contentResolver.releasePersistableUriPermission(uri, takeFlags)
+ } catch (e: Exception) {
+ Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.message)
+ }
+ return false
+ }
+
+ val citraDirectory: Uri
+ get() {
+ val directoryString = preferences.getString(CITRA_DIRECTORY, "")
+ return Uri.parse(directoryString)
+ }
+
+ fun setCitraDirectory(uriString: String?) =
+ preferences.edit().putString(CITRA_DIRECTORY, uriString).apply()
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
deleted file mode 100644
index 892b46387..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.graphics.Bitmap;
-import android.graphics.BitmapShader;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Rect;
-import android.graphics.RectF;
-
-import com.squareup.picasso.Transformation;
-
-public class PicassoRoundedCornersTransformation implements Transformation {
- @Override
- public Bitmap transform(Bitmap icon) {
- final int width = icon.getWidth();
- final int height = icon.getHeight();
- final Rect rect = new Rect(0, 0, width, height);
- final int size = Math.min(width, height);
- final int x = (width - size) / 2;
- final int y = (height - size) / 2;
-
- Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size);
- if (squaredBitmap != icon) {
- icon.recycle();
- }
-
- Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
- Canvas canvas = new Canvas(output);
- BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
- Paint paint = new Paint();
- paint.setAntiAlias(true);
- paint.setShader(shader);
-
- canvas.drawRoundRect(new RectF(rect), 10, 10, paint);
-
- squaredBitmap.recycle();
-
- return output;
- }
-
- @Override
- public String key() {
- return "circle";
- }
-}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
index 65d6d4a88..74e282beb 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
@@ -2,44 +2,14 @@ package org.citra.citra_emu.utils;
import android.graphics.Bitmap;
import android.net.Uri;
-import android.widget.ImageView;
import com.squareup.picasso.Picasso;
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-
import java.io.IOException;
import androidx.annotation.Nullable;
public class PicassoUtils {
- private static boolean mPicassoInitialized = false;
-
- public static void init() {
- if (mPicassoInitialized) {
- return;
- }
- Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext())
- .addRequestHandler(new GameIconRequestHandler())
- .build();
-
- Picasso.setSingletonInstance(picassoInstance);
- mPicassoInitialized = true;
- }
-
- public static void loadGameIcon(ImageView imageView, String gamePath) {
- Picasso
- .get()
- .load(Uri.parse(gamePath))
- .fit()
- .centerInside()
- .config(Bitmap.Config.RGB_565)
- .error(R.drawable.no_icon)
- .transform(new PicassoRoundedCornersTransformation())
- .into(imageView);
- }
-
// Blocking call. Load image from file and crop/resize it to fit in width x height.
@Nullable
public static Bitmap LoadBitmapFromFile(String uri, int width, int height) {
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt
new file mode 100644
index 000000000..400162659
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt
@@ -0,0 +1,42 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+
+@Suppress("DEPRECATION")
+object SerializableHelper {
+ inline fun Bundle.serializable(key: String): T? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializable(key, T::class.java)
+ } else {
+ getSerializable(key) as? T
+ }
+
+ inline fun Intent.serializable(key: String): T? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializableExtra(key, T::class.java)
+ } else {
+ getSerializableExtra(key) as? T
+ }
+
+ inline fun Bundle.parcelable(key: String): T? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelable(key, T::class.java)
+ } else {
+ getParcelable(key) as? T
+ }
+
+ inline fun Intent.parcelable(key: String): T? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(key, T::class.java)
+ } else {
+ getParcelableExtra(key) as? T
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
deleted file mode 100644
index 5e52529d3..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.TextUtils;
-import android.text.method.LinkMovementMethod;
-import android.widget.TextView;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.appcompat.app.AlertDialog;
-import androidx.fragment.app.FragmentActivity;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.activities.EmulationActivity;
-
-public final class StartupHandler {
- private static void handlePermissionsCheck(FragmentActivity parent,
- ActivityResultLauncher launcher) {
- // Ask the user to grant write permission if it's not already granted
- PermissionsHandler.checkWritePermission(parent, launcher);
-
- String start_file = "";
- Bundle extras = parent.getIntent().getExtras();
- if (extras != null) {
- start_file = extras.getString("AutoStartFile");
- }
-
- if (!TextUtils.isEmpty(start_file)) {
- // Start the emulation activity, send the ISO passed in and finish the main activity
- Intent emulation_intent = new Intent(parent, EmulationActivity.class);
- emulation_intent.putExtra("SelectedGame", start_file);
- parent.startActivity(emulation_intent);
- parent.finish();
- }
- }
-
- public static void HandleInit(FragmentActivity parent, ActivityResultLauncher launcher) {
- if (PermissionsHandler.isFirstBoot(parent)) {
- // Prompt user with standard first boot disclaimer
- AlertDialog dialog =
- new MaterialAlertDialogBuilder(parent)
- .setTitle(R.string.app_name)
- .setIcon(R.mipmap.ic_launcher)
- .setMessage(R.string.app_disclaimer)
- .setPositiveButton(android.R.string.ok, null)
- .setCancelable(false)
- .setOnDismissListener(
- dialogInterface -> handlePermissionsCheck(parent, launcher))
- .show();
- TextView textView = dialog.findViewById(android.R.id.message);
- if (textView == null)
- return;
- textView.setMovementMethod(LinkMovementMethod.getInstance());
- }
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
deleted file mode 100644
index d8c193665..000000000
--- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package org.citra.citra_emu.utils;
-
-import android.app.Activity;
-import android.content.SharedPreferences;
-import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.graphics.Color;
-import android.os.Build;
-import android.preference.PreferenceManager;
-
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.app.AppCompatDelegate;
-import androidx.core.content.ContextCompat;
-import androidx.core.view.WindowCompat;
-import androidx.core.view.WindowInsetsControllerCompat;
-
-import com.google.android.material.color.MaterialColors;
-
-import org.citra.citra_emu.CitraApplication;
-import org.citra.citra_emu.R;
-import org.citra.citra_emu.features.settings.utils.SettingsFile;
-
-public class ThemeUtil {
- private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
-
- public static final float NAV_BAR_ALPHA = 0.9f;
-
- private static void applyTheme(int designValue, AppCompatActivity activity) {
- switch (designValue) {
- case 0:
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
- break;
- case 1:
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
- break;
- case 2:
- AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ?
- AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM :
- AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
- break;
- }
-
- setSystemBarMode(activity, getIsLightMode(activity.getResources()));
- setNavigationBarColor(activity, MaterialColors.getColor(activity.getWindow().getDecorView(), R.attr.colorSurface));
- }
-
- public static void applyTheme(AppCompatActivity activity) {
- applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0), activity);
- }
-
- public static void setSystemBarMode(AppCompatActivity activity, boolean isLightMode) {
- WindowInsetsControllerCompat windowController = WindowCompat.getInsetsController(activity.getWindow(), activity.getWindow().getDecorView());
- windowController.setAppearanceLightStatusBars(isLightMode);
- windowController.setAppearanceLightNavigationBars(isLightMode);
- }
-
- public static void setNavigationBarColor(@NonNull Activity activity, @ColorInt int color) {
- int gestureType = InsetsHelper.getSystemGestureType(activity.getApplicationContext());
- int orientation = activity.getResources().getConfiguration().orientation;
-
- // Use a solid color when the navigation bar is on the left/right edge of the screen
- if ((gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION ||
- gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) &&
- orientation == Configuration.ORIENTATION_LANDSCAPE) {
- activity.getWindow().setNavigationBarColor(color);
- } else if (gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION ||
- gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) {
- // Use semi-transparent color when in portrait mode with three/two button navigation to
- // partially see list items behind the navigation bar
- activity.getWindow().setNavigationBarColor(ThemeUtil.getColorWithOpacity(color, NAV_BAR_ALPHA));
- } else {
- // Use transparent color when using gesture navigation
- activity.getWindow().setNavigationBarColor(
- ContextCompat.getColor(activity.getApplicationContext(),
- android.R.color.transparent));
- }
- }
-
- @ColorInt
- public static int getColorWithOpacity(@ColorInt int color, float alphaFactor) {
- return Color.argb(Math.round(alphaFactor * Color.alpha(color)), Color.red(color),
- Color.green(color), Color.blue(color));
- }
-
- public static boolean getIsLightMode(Resources resources) {
- return (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt
new file mode 100644
index 000000000..ce3d24ceb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt
@@ -0,0 +1,76 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.content.SharedPreferences
+import android.content.res.Configuration
+import android.graphics.Color
+import androidx.annotation.ColorInt
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.preference.PreferenceManager
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.features.settings.model.Settings
+import kotlin.math.roundToInt
+
+object ThemeUtil {
+ const val SYSTEM_BAR_ALPHA = 0.9f
+
+ private val preferences: SharedPreferences get() =
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+
+ fun setTheme(activity: AppCompatActivity) {
+ setThemeMode(activity)
+ }
+
+ fun setThemeMode(activity: AppCompatActivity) {
+ val themeMode = PreferenceManager.getDefaultSharedPreferences(activity.applicationContext)
+ .getInt(Settings.PREF_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ activity.delegate.localNightMode = themeMode
+ val windowController = WindowCompat.getInsetsController(
+ activity.window,
+ activity.window.decorView
+ )
+ when (themeMode) {
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) {
+ false -> setLightModeSystemBars(windowController)
+ true -> setDarkModeSystemBars(windowController)
+ }
+ AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController)
+ AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController)
+ }
+ }
+
+ private fun isNightMode(activity: AppCompatActivity): Boolean {
+ return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
+ Configuration.UI_MODE_NIGHT_NO -> false
+ Configuration.UI_MODE_NIGHT_YES -> true
+ else -> false
+ }
+ }
+
+ private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = true
+ windowController.isAppearanceLightNavigationBars = true
+ }
+
+ private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = false
+ windowController.isAppearanceLightNavigationBars = false
+ }
+
+ @ColorInt
+ fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
+ return Color.argb(
+ (alphaFactor * Color.alpha(color)).roundToInt(),
+ Color.red(color),
+ Color.green(color),
+ Color.blue(color)
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt
new file mode 100644
index 000000000..7eed05f72
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt
@@ -0,0 +1,36 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.utils
+
+import android.view.View
+
+object ViewUtils {
+ fun showView(view: View, length: Long = 300) {
+ view.apply {
+ alpha = 0f
+ visibility = View.VISIBLE
+ isClickable = true
+ }.animate().apply {
+ duration = length
+ alpha(1f)
+ }.start()
+ }
+
+ fun hideView(view: View, length: Long = 300) {
+ if (view.visibility == View.INVISIBLE) {
+ return
+ }
+
+ view.apply {
+ alpha = 1f
+ isClickable = false
+ }.animate().apply {
+ duration = length
+ alpha(0f)
+ }.withEndAction {
+ view.visibility = View.INVISIBLE
+ }.start()
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt
new file mode 100644
index 000000000..44fb8e8c4
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt
@@ -0,0 +1,150 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.viewmodel
+
+import android.net.Uri
+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.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.utils.FileUtil.asDocumentFile
+import org.citra.citra_emu.utils.GpuDriverMetadata
+import org.citra.citra_emu.utils.GpuDriverHelper
+
+class DriverViewModel : ViewModel() {
+ val areDriversLoading get() = _areDriversLoading.asStateFlow()
+ private val _areDriversLoading = MutableStateFlow(false)
+
+ val isDriverReady get() = _isDriverReady.asStateFlow()
+ private val _isDriverReady = MutableStateFlow(true)
+
+ val isDeletingDrivers get() = _isDeletingDrivers.asStateFlow()
+ private val _isDeletingDrivers = MutableStateFlow(false)
+
+ val driverList get() = _driverList.asStateFlow()
+ private val _driverList = MutableStateFlow(mutableListOf>())
+
+ var previouslySelectedDriver = 0
+ var selectedDriver = -1
+
+ private val _selectedDriverMetadata =
+ MutableStateFlow(
+ GpuDriverHelper.customDriverData.name
+ ?: CitraApplication.appContext.getString(R.string.system_gpu_driver)
+ )
+ val selectedDriverMetadata: StateFlow get() = _selectedDriverMetadata
+
+ private val _newDriverInstalled = MutableStateFlow(false)
+ val newDriverInstalled: StateFlow get() = _newDriverInstalled
+
+ val driversToDelete = mutableListOf()
+
+ 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
+ }
+ }
+
+ _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) {
+ val driverIndex = _driverList.value.indexOfFirst { it == driverData }
+ if (driverIndex == -1) {
+ setSelectedDriverIndex(_driverList.value.size)
+ _driverList.value.add(driverData)
+ _selectedDriverMetadata.value = driverData.second.name
+ ?: CitraApplication.appContext.getString(R.string.system_gpu_driver)
+ } else {
+ setSelectedDriverIndex(driverIndex)
+ }
+ }
+
+ fun removeDriver(driverData: Pair) {
+ _driverList.value.remove(driverData)
+ }
+
+ fun onCloseDriverManager() {
+ _isDeletingDrivers.value = true
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ for (driverUri in driversToDelete) {
+ val driver = driverUri.asDocumentFile() ?: continue
+ 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 = driverList.value[selectedDriver].first.asDocumentFile()
+ if (driverToInstall == null) {
+ GpuDriverHelper.installDefaultDriver()
+ return@withContext
+ }
+
+ if (driverToInstall.exists()) {
+ if (!GpuDriverHelper.installCustomDriverPartial(driverToInstall.uri)) {
+ return@withContext
+ }
+ } else {
+ GpuDriverHelper.installDefaultDriver()
+ }
+ setDriverReady()
+ }
+ }
+ }
+
+ private fun setDriverReady() {
+ _isDriverReady.value = true
+ _selectedDriverMetadata.value = GpuDriverHelper.customDriverData.name
+ ?: CitraApplication.appContext.getString(R.string.system_gpu_driver)
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt
new file mode 100644
index 000000000..0f7ed4291
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt
@@ -0,0 +1,121 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.viewmodel
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.preference.PreferenceManager
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.model.Game
+import org.citra.citra_emu.utils.GameHelper
+
+class GamesViewModel : ViewModel() {
+ val games get() = _games.asStateFlow()
+ private val _games = MutableStateFlow(emptyList())
+
+ val searchedGames get() = _searchedGames.asStateFlow()
+ private val _searchedGames = MutableStateFlow(emptyList())
+
+ val isReloading get() = _isReloading.asStateFlow()
+ private val _isReloading = MutableStateFlow(false)
+
+ val shouldSwapData get() = _shouldSwapData.asStateFlow()
+ private val _shouldSwapData = MutableStateFlow(false)
+
+ val shouldScrollToTop get() = _shouldScrollToTop.asStateFlow()
+ private val _shouldScrollToTop = MutableStateFlow(false)
+
+ val searchFocused get() = _searchFocused.asStateFlow()
+ private val _searchFocused = MutableStateFlow(false)
+
+ init {
+ // Retrieve list of cached games
+ val storedGames = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ .getStringSet(GameHelper.KEY_GAMES, emptySet())
+ if (storedGames!!.isNotEmpty()) {
+ val deserializedGames = mutableSetOf()
+ storedGames.forEach {
+ val game: Game
+ try {
+ game = Json.decodeFromString(it)
+ } catch (ignored: Exception) {
+ return@forEach
+ }
+
+ val gameExists =
+ DocumentFile.fromSingleUri(CitraApplication.appContext, Uri.parse(game.path))
+ ?.exists()
+ if (gameExists == true) {
+ deserializedGames.add(game)
+ } else if (game.isInstalled) {
+ deserializedGames.add(game)
+ }
+ }
+ setGames(deserializedGames.toList())
+ }
+ reloadGames(false)
+ }
+
+ fun setGames(games: List) {
+ val sortedList = games.sortedWith(
+ compareBy(
+ { it.title.lowercase(Locale.getDefault()) },
+ { it.path }
+ )
+ )
+ val filteredList = sortedList.filter {
+ if (it.isSystemTitle) {
+ it.isVisibleSystemTitle
+ }
+ true
+ }
+
+ _games.value = filteredList
+ }
+
+ fun setSearchedGames(games: List) {
+ _searchedGames.value = games
+ }
+
+ fun setShouldSwapData(shouldSwap: Boolean) {
+ _shouldSwapData.value = shouldSwap
+ }
+
+ fun setShouldScrollToTop(shouldScroll: Boolean) {
+ _shouldScrollToTop.value = shouldScroll
+ }
+
+ fun setSearchFocused(searchFocused: Boolean) {
+ _searchFocused.value = searchFocused
+ }
+
+ fun reloadGames(directoryChanged: Boolean) {
+ if (isReloading.value) {
+ return
+ }
+ _isReloading.value = true
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ setGames(GameHelper.getGames())
+ _isReloading.value = false
+
+ if (directoryChanged) {
+ setShouldSwapData(true)
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt
new file mode 100644
index 000000000..32b2449fb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt
@@ -0,0 +1,114 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.viewmodel
+
+import android.content.res.Resources
+import android.net.Uri
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.citra.citra_emu.CitraApplication
+import org.citra.citra_emu.R
+import org.citra.citra_emu.fragments.CitraDirectoryDialogFragment
+import org.citra.citra_emu.utils.GameHelper
+import org.citra.citra_emu.utils.PermissionsHandler
+
+class HomeViewModel : ViewModel() {
+ val navigationVisible get() = _navigationVisible.asStateFlow()
+ private val _navigationVisible = MutableStateFlow(Pair(false, false))
+
+ val statusBarShadeVisible get() = _statusBarShadeVisible.asStateFlow()
+ private val _statusBarShadeVisible = MutableStateFlow(true)
+
+ val isPickingUserDir get() = _isPickingUserDir.asStateFlow()
+ private val _isPickingUserDir = MutableStateFlow(false)
+
+ val userDir get() = _userDir.asStateFlow()
+ private val _userDir = MutableStateFlow(
+ Uri.parse(
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ .getString(PermissionsHandler.CITRA_DIRECTORY, "")
+ ).path ?: ""
+ )
+
+ val gamesDir get() = _gamesDir.asStateFlow()
+ private val _gamesDir = MutableStateFlow(
+ Uri.parse(
+ PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
+ .getString(GameHelper.KEY_GAME_PATH, "")
+ ).path ?: ""
+ )
+
+ var directoryListener: CitraDirectoryDialogFragment.Listener? = null
+
+ val dirProgress get() = _dirProgress.asStateFlow()
+ private val _dirProgress = MutableStateFlow(0)
+
+ val maxDirProgress get() = _maxDirProgress.asStateFlow()
+ private val _maxDirProgress = MutableStateFlow(0)
+
+ val messageText get() = _messageText.asStateFlow()
+ private val _messageText = MutableStateFlow("")
+
+ val copyComplete get() = _copyComplete.asStateFlow()
+ private val _copyComplete = MutableStateFlow(false)
+
+ var copyInProgress = false
+
+ var navigatedToSetup = false
+
+ fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
+ if (_navigationVisible.value.first == visible) {
+ return
+ }
+ _navigationVisible.value = Pair(visible, animated)
+ }
+
+ fun setStatusBarShadeVisibility(visible: Boolean) {
+ if (_statusBarShadeVisible.value == visible) {
+ return
+ }
+ _statusBarShadeVisible.value = visible
+ }
+
+ fun setPickingUserDir(picking: Boolean) {
+ _isPickingUserDir.value = picking
+ }
+
+ fun setUserDir(activity: FragmentActivity, dir: String) {
+ ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+ _userDir.value = dir
+ }
+
+ fun setGamesDir(activity: FragmentActivity, dir: String) {
+ ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+ _gamesDir.value = dir
+ }
+
+ fun clearCopyInfo() {
+ _messageText.value = ""
+ _dirProgress.value = 0
+ _maxDirProgress.value = 0
+ _copyComplete.value = false
+ copyInProgress = false
+ }
+
+ fun onUpdateSearchProgress(resources: Resources, directoryName: String) {
+ _messageText.value = resources.getString(R.string.searching_directory, directoryName)
+ }
+
+ fun onUpdateCopyProgress(resources: Resources, filename: String, progress: Int, max: Int) {
+ _messageText.value = resources.getString(R.string.copy_file_name, filename)
+ _dirProgress.value = progress
+ _maxDirProgress.value = max
+ }
+
+ fun setCopyComplete(complete: Boolean) {
+ _copyComplete.value = complete
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt
new file mode 100644
index 000000000..d4f654d5c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt
@@ -0,0 +1,139 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.viewmodel
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import org.citra.citra_emu.NativeLibrary
+import org.citra.citra_emu.NativeLibrary.InstallStatus
+import org.citra.citra_emu.utils.Log
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.coroutines.CoroutineContext
+import kotlin.math.min
+
+class SystemFilesViewModel : ViewModel() {
+ private var job: Job
+ private val coroutineContext: CoroutineContext
+ get() = Dispatchers.IO + job
+
+ val isDownloading get() = _isDownloading.asStateFlow()
+ private val _isDownloading = MutableStateFlow(false)
+
+ val progress get() = _progress.asStateFlow()
+ private val _progress = MutableStateFlow(0)
+
+ val result get() = _result.asStateFlow()
+ private val _result = MutableStateFlow(null)
+
+ val shouldRefresh get() = _shouldRefresh.asStateFlow()
+ private val _shouldRefresh = MutableStateFlow(false)
+
+ private var cancelled = false
+
+ private val RETRY_AMOUNT = 3
+
+ init {
+ job = Job()
+ clear()
+ }
+
+ fun setShouldRefresh(refresh: Boolean) {
+ _shouldRefresh.value = refresh
+ }
+
+ fun setProgress(progress: Int) {
+ _progress.value = progress
+ }
+
+ fun download(titles: LongArray) {
+ if (isDownloading.value) {
+ return
+ }
+ clear()
+ _isDownloading.value = true
+ Log.debug("System menu download started.")
+
+ val minExecutors = min(Runtime.getRuntime().availableProcessors(), titles.size)
+ val segment = (titles.size / minExecutors)
+ val atomicProgress = AtomicInteger(0)
+ for (i in 0 until minExecutors) {
+ val titlesSegment = if (i < minExecutors - 1) {
+ titles.copyOfRange(i * segment, (i + 1) * segment)
+ } else {
+ titles.copyOfRange(i * segment, titles.size)
+ }
+
+ CoroutineScope(coroutineContext).launch {
+ titlesSegment.forEach { title: Long ->
+ // Notify UI of cancellation before ending coroutine
+ if (cancelled) {
+ _result.value = InstallStatus.ErrorAborted
+ cancelled = false
+ }
+
+ // Takes a moment to see if the coroutine was cancelled
+ yield()
+
+ // Retry downloading a title repeatedly
+ for (j in 0 until RETRY_AMOUNT) {
+ val result = tryDownloadTitle(title)
+ if (result == InstallStatus.Success) {
+ break
+ } else if (j == RETRY_AMOUNT - 1) {
+ _result.value = result
+ return@launch
+ }
+ Log.warning("Download for title{$title} failed, retrying in 3s...")
+ delay(3000L)
+ }
+
+ Log.debug("Successfully installed title - $title")
+ setProgress(atomicProgress.incrementAndGet())
+
+ Log.debug("System File Progress - ${atomicProgress.get()} / ${titles.size}")
+ if (atomicProgress.get() == titles.size) {
+ _result.value = InstallStatus.Success
+ setShouldRefresh(true)
+ }
+ }
+ }
+ }
+ }
+
+ private fun tryDownloadTitle(title: Long): InstallStatus {
+ val result = NativeLibrary.downloadTitleFromNus(title)
+ if (result != InstallStatus.Success) {
+ Log.error("Failed to install title $title with error - $result")
+ }
+ return result
+ }
+
+ fun clear() {
+ Log.debug("Clearing")
+ job.cancelChildren()
+ job = Job()
+ _progress.value = 0
+ _result.value = null
+ _isDownloading.value = false
+ cancelled = false
+ }
+
+ fun cancel() {
+ Log.debug("Canceling system file download.")
+ cancelled = true
+ job.cancelChildren()
+ job = Job()
+ _progress.value = 0
+ _result.value = InstallStatus.Cancelled
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt
new file mode 100644
index 000000000..54999e4c3
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt
@@ -0,0 +1,59 @@
+// Copyright 2023 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+class TaskViewModel : ViewModel() {
+ val result: StateFlow get() = _result
+ private val _result = MutableStateFlow(Any())
+
+ val isComplete: StateFlow get() = _isComplete
+ private val _isComplete = MutableStateFlow(false)
+
+ val isRunning: StateFlow get() = _isRunning
+ private val _isRunning = MutableStateFlow(false)
+
+ val cancelled: StateFlow 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() {
+ if (isRunning.value) {
+ return
+ }
+ _isRunning.value = true
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val res = task()
+ _result.value = res
+ _isComplete.value = true
+ _isRunning.value = false
+ }
+ }
+}
+
+enum class TaskState {
+ Completed,
+ Failed,
+ Cancelled
+}
diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
index 6668f9f0d..bc0a6b94a 100644
--- a/src/android/app/src/main/jni/CMakeLists.txt
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -29,7 +29,6 @@ add_library(citra-android SHARED
id_cache.cpp
id_cache.h
native.cpp
- native.h
ndk_motion.cpp
ndk_motion.h
)
diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp
index b4864a3fb..e0808b7f3 100644
--- a/src/android/app/src/main/jni/config.cpp
+++ b/src/android/app/src/main/jni/config.cpp
@@ -139,9 +139,6 @@ void Config::ReadValues() {
ReadSetting("Core", Settings::values.use_cpu_jit);
ReadSetting("Core", Settings::values.cpu_clock_percentage);
- // Premium
- ReadSetting("Premium", Settings::values.texture_filter);
-
// Renderer
Settings::values.use_gles = sdl2_config->GetBoolean("Renderer", "use_gles", true);
Settings::values.shaders_accurate_mul =
@@ -155,6 +152,7 @@ void Config::ReadValues() {
ReadSetting("Renderer", Settings::values.resolution_factor);
ReadSetting("Renderer", Settings::values.use_disk_shader_cache);
ReadSetting("Renderer", Settings::values.use_vsync_new);
+ ReadSetting("Renderer", Settings::values.texture_filter);
// Work around to map Android setting for enabling the frame limiter to the format Citra expects
if (sdl2_config->GetBoolean("Renderer", "use_frame_limit", true)) {
diff --git a/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp b/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp
index 81dc69c4a..70b216197 100644
--- a/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp
+++ b/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp
@@ -51,3 +51,7 @@ bool EmuWindow_Android_Vulkan::CreateWindowSurface() {
std::unique_ptr EmuWindow_Android_Vulkan::CreateSharedContext() const {
return std::make_unique(driver_library);
}
+
+std::shared_ptr EmuWindow_Android_Vulkan::GetDriverLibrary() {
+ return driver_library;
+}
diff --git a/src/android/app/src/main/jni/emu_window/emu_window_vk.h b/src/android/app/src/main/jni/emu_window/emu_window_vk.h
index 58bbd3092..fe54f9a36 100644
--- a/src/android/app/src/main/jni/emu_window/emu_window_vk.h
+++ b/src/android/app/src/main/jni/emu_window/emu_window_vk.h
@@ -18,6 +18,8 @@ public:
std::unique_ptr CreateSharedContext() const override;
+ std::shared_ptr GetDriverLibrary() override;
+
private:
bool CreateWindowSurface() override;
diff --git a/src/android/app/src/main/jni/game_info.cpp b/src/android/app/src/main/jni/game_info.cpp
index 0f64b34a3..b80d0dc7a 100644
--- a/src/android/app/src/main/jni/game_info.cpp
+++ b/src/android/app/src/main/jni/game_info.cpp
@@ -147,4 +147,13 @@ jintArray Java_org_citra_citra_1emu_model_GameInfo_getIcon(JNIEnv* env, jobject
return icon;
}
+
+jboolean Java_org_citra_citra_1emu_model_GameInfo_getIsVisibleSystemTitle(JNIEnv* env,
+ jobject obj) {
+ Loader::SMDH* smdh = GetPointer(env, obj);
+ if (smdh == nullptr) {
+ return false;
+ }
+ return smdh->flags & Loader::SMDH::Flags::Visible;
+}
}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index e737d19ee..e7be21279 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -25,9 +25,6 @@ static jclass s_savestate_info_class;
static jclass s_native_library_class;
static jmethodID s_on_core_error;
-static jmethodID s_display_alert_msg;
-static jmethodID s_display_alert_prompt;
-static jmethodID s_alert_prompt_button;
static jmethodID s_is_portrait_mode;
static jmethodID s_landscape_screen_layout;
static jmethodID s_exit_emulation_activity;
@@ -87,18 +84,6 @@ jmethodID GetOnCoreError() {
return s_on_core_error;
}
-jmethodID GetDisplayAlertMsg() {
- return s_display_alert_msg;
-}
-
-jmethodID GetDisplayAlertPrompt() {
- return s_display_alert_prompt;
-}
-
-jmethodID GetAlertPromptButton() {
- return s_alert_prompt_button;
-}
-
jmethodID GetIsPortraitMode() {
return s_is_portrait_mode;
}
@@ -182,7 +167,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
// Initialize misc classes
s_savestate_info_class = reinterpret_cast(
- env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SavestateInfo")));
+ env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SaveStateInfo")));
s_core_error_class = reinterpret_cast(
env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$CoreError")));
@@ -190,24 +175,17 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
const jclass native_library_class = env->FindClass("org/citra/citra_emu/NativeLibrary");
s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class));
s_on_core_error = env->GetStaticMethodID(
- s_native_library_class, "OnCoreError",
+ s_native_library_class, "onCoreError",
"(Lorg/citra/citra_emu/NativeLibrary$CoreError;Ljava/lang/String;)Z");
- s_display_alert_msg = env->GetStaticMethodID(s_native_library_class, "displayAlertMsg",
- "(Ljava/lang/String;Ljava/lang/String;Z)Z");
- s_display_alert_prompt =
- env->GetStaticMethodID(s_native_library_class, "displayAlertPrompt",
- "(Ljava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
- s_alert_prompt_button =
- env->GetStaticMethodID(s_native_library_class, "alertPromptButton", "()I");
s_is_portrait_mode = env->GetStaticMethodID(s_native_library_class, "isPortraitMode", "()Z");
s_landscape_screen_layout =
env->GetStaticMethodID(s_native_library_class, "landscapeScreenLayout", "()I");
s_exit_emulation_activity =
env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V");
s_request_camera_permission =
- env->GetStaticMethodID(s_native_library_class, "RequestCameraPermission", "()Z");
+ env->GetStaticMethodID(s_native_library_class, "requestCameraPermission", "()Z");
s_request_mic_permission =
- env->GetStaticMethodID(s_native_library_class, "RequestMicPermission", "()Z");
+ env->GetStaticMethodID(s_native_library_class, "requestMicPermission", "()Z");
env->DeleteLocalRef(native_library_class);
// Initialize Cheat
@@ -225,7 +203,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
// Initialize GameInfo
const jclass game_info_class = env->FindClass("org/citra/citra_emu/model/GameInfo");
- s_game_info_pointer = env->GetFieldID(game_info_class, "mPointer", "J");
+ s_game_info_pointer = env->GetFieldID(game_info_class, "pointer", "J");
env->DeleteLocalRef(game_info_class);
// Initialize Disk Shader Cache Progress Dialog
@@ -262,13 +240,13 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
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");
+ env->FindClass("org/citra/citra_emu/NativeLibrary$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;")));
+ "Lorg/citra/citra_emu/"
+ "NativeLibrary$InstallStatus;")));
};
s_java_cia_install_status.emplace(Service::AM::InstallStatus::Success,
to_java_cia_install_status("Success"));
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 1ce3b4a33..dbd845a84 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -3,12 +3,17 @@
// Refer to the license.txt file included.
#include
+#include
#include
#include
#include
#include
+#include
+#include
+#include
+#include
#include "audio_core/dsp_interface.h"
#include "common/arch.h"
#if CITRA_ARCH(arm64)
@@ -45,7 +50,6 @@
#include "jni/game_settings.h"
#include "jni/id_cache.h"
#include "jni/input_manager.h"
-#include "jni/native.h"
#include "jni/ndk_motion.h"
#include "video_core/renderer_base.h"
#include "video_core/video_core.h"
@@ -60,6 +64,7 @@ ANativeWindow* s_surf;
std::shared_ptr vulkan_library{};
std::unique_ptr window;
+std::shared_ptr cfg;
std::atomic stop_run{true};
std::atomic pause_emulation{false};
@@ -277,8 +282,8 @@ void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& cus
extern "C" {
-void Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+void Java_org_citra_citra_1emu_NativeLibrary_surfaceChanged(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jobject surf) {
s_surf = ANativeWindow_fromSurface(env, surf);
@@ -292,8 +297,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env,
LOG_INFO(Frontend, "Surface changed");
}
-void Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_surfaceDestroyed([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
ANativeWindow_release(s_surf);
s_surf = nullptr;
if (window) {
@@ -301,24 +306,23 @@ void Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env,
}
}
-void Java_org_citra_citra_1emu_NativeLibrary_DoFrame(JNIEnv* env, [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_doFrame([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
if (stop_run || pause_emulation) {
return;
}
window->TryPresenting();
}
-void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz,
- jstring hook_lib_dir,
- jstring custom_driver_dir,
- jstring custom_driver_name,
- jstring file_redirect_dir) {
+void JNICALL Java_org_citra_citra_1emu_NativeLibrary_initializeGpuDriver(
+ JNIEnv* env, jobject obj, jstring hook_lib_dir, jstring custom_driver_dir,
+ jstring custom_driver_name, jstring file_redirect_dir) {
InitializeGpuDriver(GetJString(env, hook_lib_dir), GetJString(env, custom_driver_dir),
GetJString(env, custom_driver_name), GetJString(env, file_redirect_dir));
}
-void Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+void Java_org_citra_citra_1emu_NativeLibrary_notifyOrientationChange([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jint layout_option,
jint rotation) {
Settings::values.layout_option = static_cast(layout_option);
@@ -329,7 +333,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env
Camera::NDK::g_rotation = rotation;
}
-void Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, [[maybe_unused]] jclass clazz,
+void Java_org_citra_citra_1emu_NativeLibrary_swapScreens([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jboolean swap_screens, jint rotation) {
Settings::values.swap_screen = swap_screens;
if (VideoCore::g_renderer) {
@@ -339,13 +344,30 @@ void Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, [[maybe_un
Camera::NDK::g_rotation = rotation;
}
-void Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+jboolean Java_org_citra_citra_1emu_NativeLibrary_areKeysAvailable([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
+ HW::AES::InitKeys();
+ return HW::AES::IsKeyXAvailable(HW::AES::KeySlotID::NCCHSecure1) &&
+ HW::AES::IsKeyXAvailable(HW::AES::KeySlotID::NCCHSecure2);
+}
+
+jstring Java_org_citra_citra_1emu_NativeLibrary_getHomeMenuPath(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ jint region) {
+ const std::string path = Core::GetHomeMenuNcchPath(region);
+ if (FileUtil::Exists(path)) {
+ return ToJString(env, path);
+ }
+ return ToJString(env, "");
+}
+
+void Java_org_citra_citra_1emu_NativeLibrary_setUserDirectory(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jstring j_directory) {
FileUtil::SetCurrentDir(GetJString(env, j_directory));
}
-jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(
+jobjectArray Java_org_citra_citra_1emu_NativeLibrary_getInstalledGamePaths(
JNIEnv* env, [[maybe_unused]] jclass clazz) {
std::vector games;
const FileUtil::DirectoryEntryCallable ScanDir =
@@ -383,44 +405,87 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(
return jgames;
}
+jlongArray Java_org_citra_citra_1emu_NativeLibrary_getSystemTitleIds(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ jint system_type,
+ jint region) {
+ const auto mode = static_cast(system_type);
+ const std::vector titles = Core::GetSystemTitleIds(mode, region);
+ jlongArray jTitles = env->NewLongArray(titles.size());
+ env->SetLongArrayRegion(jTitles, 0, titles.size(),
+ reinterpret_cast(titles.data()));
+ return jTitles;
+}
+
+jobject Java_org_citra_citra_1emu_NativeLibrary_downloadTitleFromNus([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ jlong title) {
+ const auto title_id = static_cast(title);
+ Service::AM::InstallStatus status = Service::AM::InstallFromNus(title_id);
+ if (status != Service::AM::InstallStatus::Success) {
+ return IDCache::GetJavaCiaInstallStatus(status);
+ }
+ return IDCache::GetJavaCiaInstallStatus(Service::AM::InstallStatus::Success);
+}
+
+[[maybe_unused]] static bool CheckKgslPresent() {
+ constexpr auto KgslPath{"/dev/kgsl-3d0"};
+
+ return access(KgslPath, F_OK) == 0;
+}
+
+[[maybe_unused]] bool SupportsCustomDriver() {
+ return android_get_device_api_level() >= 28 && CheckKgslPresent();
+}
+
+jboolean JNICALL Java_org_citra_citra_1emu_utils_GpuDriverHelper_supportsCustomDriverLoading(
+ JNIEnv* env, jobject instance) {
+#ifdef CITRA_ARCH_arm64
+ // If the KGSL device exists custom drivers can be loaded using adrenotools
+ return SupportsCustomDriver();
+#else
+ return false;
+#endif
+}
+
// TODO(xperia64): ensure these cannot be called in an invalid state (e.g. after StopEmulation)
-void Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_unPauseEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
pause_emulation = false;
running_cv.notify_all();
InputManager::NDKMotionHandler()->EnableSensors();
}
-void Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_pauseEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
pause_emulation = true;
InputManager::NDKMotionHandler()->DisableSensors();
}
-void Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_stopEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
stop_run = true;
pause_emulation = false;
window->StopPresenting();
running_cv.notify_all();
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+jboolean Java_org_citra_citra_1emu_NativeLibrary_isRunning([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
return static_cast(!stop_run);
}
-jlong Java_org_citra_citra_1emu_NativeLibrary_GetRunningTitleId(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+jlong Java_org_citra_citra_1emu_NativeLibrary_getRunningTitleId([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
u64 title_id{};
Core::System::GetInstance().GetAppLoader().ReadProgramId(title_id);
return static_cast(title_id);
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
- jstring j_device, jint j_button,
- jint action) {
+jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ [[maybe_unused]] jstring j_device,
+ jint j_button, jint action) {
bool consumed{};
if (action) {
consumed = InputManager::ButtonHandler()->PressKey(j_button);
@@ -431,10 +496,9 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(JNIEnv* env,
return static_cast(consumed);
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
- jstring j_device, jint axis,
- jfloat x, jfloat y) {
+jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj, [[maybe_unused]] jstring j_device,
+ jint axis, jfloat x, jfloat y) {
// Clamp joystick movement to supported minimum and maximum
// Citra uses an inverted y axis sent by the frontend
x = std::clamp(x, -1.f, 1.f);
@@ -451,29 +515,28 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(JNIEnv* env,
return static_cast(InputManager::AnalogHandler()->MoveJoystick(axis, x, y));
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
- jstring j_device, jint axis_id,
- jfloat axis_val) {
+jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj, [[maybe_unused]] jstring j_device,
+ jint axis_id, jfloat axis_val) {
return static_cast(
InputManager::ButtonHandler()->AnalogButtonEvent(axis_id, axis_val));
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+jboolean Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jfloat x, jfloat y,
jboolean pressed) {
return static_cast(
window->OnTouchEvent(static_cast(x + 0.5), static_cast(y + 0.5), pressed));
}
-void Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env,
- [[maybe_unused]] jclass clazz, jfloat x,
+void Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj, jfloat x,
jfloat y) {
window->OnTouchMoved((int)x, (int)y);
}
-jlong Java_org_citra_citra_1emu_NativeLibrary_GetTitleId(JNIEnv* env, [[maybe_unused]] jclass clazz,
+jlong Java_org_citra_citra_1emu_NativeLibrary_getTitleId(JNIEnv* env, [[maybe_unused]] jobject obj,
jstring j_filename) {
std::string filepath = GetJString(env, j_filename);
const auto loader = Loader::GetLoader(filepath);
@@ -485,42 +548,44 @@ jlong Java_org_citra_citra_1emu_NativeLibrary_GetTitleId(JNIEnv* env, [[maybe_un
return static_cast(title_id);
}
-jstring Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
- return nullptr;
+jboolean Java_org_citra_citra_1emu_NativeLibrary_getIsSystemTitle(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ jstring path) {
+ const std::string filepath = GetJString(env, path);
+ const auto loader = Loader::GetLoader(filepath);
+
+ // Since we also read through invalid file extensions, we have to check if the loader is valid
+ if (loader == nullptr) {
+ return false;
+ }
+
+ u64 program_id = 0;
+ loader->ReadProgramId(program_id);
+ return ((program_id >> 32) & 0xFFFFFFFF) == 0x00040010;
}
-void Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_createConfigFile([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
Config{};
}
-void Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_createLogFile([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
Common::Log::Initialize();
Common::Log::Start();
LOG_INFO(Frontend, "Logging backend initialised");
}
-void Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+void Java_org_citra_citra_1emu_NativeLibrary_logUserDirectory(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jstring j_path) {
std::string_view path = env->GetStringUTFChars(j_path, 0);
LOG_INFO(Frontend, "User directory path: {}", path);
env->ReleaseStringUTFChars(j_path, path.data());
}
-jint Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
- return 0;
-}
-
-void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z(
- JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_file, jstring j_savestate,
- jboolean j_delete_savestate) {}
-
-void Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
Config{};
Core::System& system{Core::System::GetInstance()};
@@ -534,51 +599,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env,
system.ApplySettings();
}
-jstring Java_org_citra_citra_1emu_NativeLibrary_GetUserSetting(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
- jstring j_game_id, jstring j_section,
- jstring j_key) {
- std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
- std::string_view section = env->GetStringUTFChars(j_section, 0);
- std::string_view key = env->GetStringUTFChars(j_key, 0);
-
- // TODO
-
- env->ReleaseStringUTFChars(j_game_id, game_id.data());
- env->ReleaseStringUTFChars(j_section, section.data());
- env->ReleaseStringUTFChars(j_key, key.data());
-
- return env->NewStringUTF("");
-}
-
-void Java_org_citra_citra_1emu_NativeLibrary_SetUserSetting(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
- jstring j_game_id, jstring j_section,
- jstring j_key, jstring j_value) {
- std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
- std::string_view section = env->GetStringUTFChars(j_section, 0);
- std::string_view key = env->GetStringUTFChars(j_key, 0);
- std::string_view value = env->GetStringUTFChars(j_value, 0);
-
- // TODO
-
- env->ReleaseStringUTFChars(j_game_id, game_id.data());
- env->ReleaseStringUTFChars(j_section, section.data());
- env->ReleaseStringUTFChars(j_key, key.data());
- env->ReleaseStringUTFChars(j_value, value.data());
-}
-
-void Java_org_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env, [[maybe_unused]] jclass clazz,
- jstring j_game_id) {
- std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
-
- // TODO
-
- env->ReleaseStringUTFChars(j_game_id, game_id.data());
-}
-
-jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env,
- [[maybe_unused]] jclass clazz) {
+jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_getPerfStats(JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
auto& core = Core::System::GetInstance();
jdoubleArray j_stats = env->NewDoubleArray(4);
@@ -595,15 +617,8 @@ jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env,
return j_stats;
}
-void Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory(
- JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_path) {
- std::string_view path = env->GetStringUTFChars(j_path, 0);
-
- env->ReleaseStringUTFChars(j_path, path.data());
-}
-
-void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(JNIEnv* env,
- [[maybe_unused]] jclass clazz,
+void Java_org_citra_citra_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jstring j_path) {
const std::string path = GetJString(env, j_path);
@@ -619,13 +634,15 @@ void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(JNIEnv* en
}
}
-void Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env, jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_reloadCameraDevices([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
if (g_ndk_factory) {
g_ndk_factory->ReloadCameraDevices();
}
}
-jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz,
+jboolean Java_org_citra_citra_1emu_NativeLibrary_loadAmiibo(JNIEnv* env,
+ [[maybe_unused]] jobject obj,
jstring j_file) {
std::string filepath = GetJString(env, j_file);
Core::System& system{Core::System::GetInstance()};
@@ -638,7 +655,8 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass
return static_cast(nfc->LoadAmiibo(filepath));
}
-void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_removeAmiibo([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
Core::System& system{Core::System::GetInstance()};
Service::SM::ServiceManager& sm = system.ServiceManager();
auto nfc = sm.GetService("nfc:u");
@@ -649,7 +667,7 @@ void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass cl
nfc->RemoveAmiibo();
}
-JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_InstallCIA(
+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 =
@@ -661,8 +679,8 @@ JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_Insta
return IDCache::GetJavaCiaInstallStatus(res);
}
-jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(
- JNIEnv* env, [[maybe_unused]] jclass clazz) {
+jobjectArray Java_org_citra_citra_1emu_NativeLibrary_getSavestateInfo(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
const jclass date_class = env->FindClass("java/util/Date");
const auto date_constructor = env->GetMethodID(date_class, "", "(J)V");
@@ -695,15 +713,18 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(
return array;
}
-void Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz, jint slot) {
+void Java_org_citra_citra_1emu_NativeLibrary_saveState([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj, jint slot) {
Core::System::GetInstance().SendSignal(Core::System::Signal::Save, slot);
}
-void Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz, jint slot) {
+void Java_org_citra_citra_1emu_NativeLibrary_loadState([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj, jint slot) {
Core::System::GetInstance().SendSignal(Core::System::Signal::Load, slot);
}
-void Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass clazz) {
+void Java_org_citra_citra_1emu_NativeLibrary_logDeviceInfo([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
LOG_INFO(Frontend, "Citra Version: {} | {}-{}", Common::g_build_fullname, Common::g_scm_branch,
Common::g_scm_desc);
LOG_INFO(Frontend, "Host CPU: {}", Common::GetCPUCaps().cpu_string);
@@ -711,4 +732,29 @@ void Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass c
LOG_INFO(Frontend, "Host OS: Android API level {}", android_get_device_api_level());
}
+void Java_org_citra_citra_1emu_NativeLibrary_loadSystemConfig([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
+ if (Core::System::GetInstance().IsPoweredOn()) {
+ cfg = Service::CFG::GetModule(Core::System::GetInstance());
+ } else {
+ cfg = std::make_shared();
+ }
+}
+
+void Java_org_citra_citra_1emu_NativeLibrary_saveSystemConfig([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj) {
+ cfg->UpdateConfigNANDSavegame();
+}
+
+void Java_org_citra_citra_1emu_NativeLibrary_setSystemSetupNeeded([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jobject obj,
+ jboolean needed) {
+ cfg->SetSystemSetupNeeded(needed);
+}
+
+jboolean Java_org_citra_citra_1emu_NativeLibrary_getIsSystemSetupNeeded(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return cfg->IsSystemSetupNeeded();
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
deleted file mode 100644
index 5733a7b56..000000000
--- a/src/android/app/src/main/jni/native.h
+++ /dev/null
@@ -1,167 +0,0 @@
-// Copyright 2019 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-#pragma once
-
-#include
-
-// Function calls from the Java side
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(
- JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action);
-
-JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(
- JNIEnv* env, jclass clazz, jstring j_device, jint axis, jfloat x, jfloat y);
-
-JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent(
- JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val);
-
-JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env,
- jclass clazz,
- jfloat x, jfloat y,
- jboolean pressed);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env,
- jclass clazz, jfloat x,
- jfloat y);
-
-JNIEXPORT jintArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetIcon(JNIEnv* env,
- jclass clazz,
- jstring j_file);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetTitle(JNIEnv* env,
- jclass clazz,
- jstring j_filename);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetDescription(
- JNIEnv* env, jclass clazz, jstring j_filename);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGameId(JNIEnv* env,
- jclass clazz,
- jstring j_filename);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetRegions(JNIEnv* env,
- jclass clazz,
- jstring j_filename);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetCompany(JNIEnv* env,
- jclass clazz,
- jstring j_filename);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory(
- JNIEnv* env, jclass clazz, jstring j_directory);
-
-JNIEXPORT jobjectArray JNICALL
-Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(JNIEnv* env, jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory(
- JNIEnv* env, jclass clazz, jstring path_);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env,
- jclass clazz,
- jstring path);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env,
- jclass clazz,
- jstring path);
-
-JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env,
- jclass clazz);
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetProfiling(JNIEnv* env,
- jclass clazz,
- jboolean enable);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(
- JNIEnv* env, jclass clazz, jint layout_option, jint rotation);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env,
- jclass clazz,
- jboolean swap_screens,
- jint rotation);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(
- JNIEnv* env, jclass clazz, jstring j_path);
-
-JNIEXPORT void JNICALL
-Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z(
- JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env,
- jclass clazz,
- jobject surf);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env,
- jclass clazz,
- jstring j_game_id);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetUserSetting(
- JNIEnv* env, jclass clazz, jstring j_game_id, jstring j_section, jstring j_key,
- jstring j_value);
-
-JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetUserSetting(
- JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key);
-
-JNIEXPORT jdoubleArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env,
- jclass clazz);
-
-JNIEXPORT jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz,
- jstring j_file);
-
-JNIEXPORT void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz);
-
-JNIEXPORT jobjectArray JNICALL
-Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(JNIEnv* env, jclass clazz);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz,
- jint slot);
-
-JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz,
- jint slot);
-
-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/jni/ndk_motion.cpp b/src/android/app/src/main/jni/ndk_motion.cpp
index 0eab444a9..9f4d9b9d2 100644
--- a/src/android/app/src/main/jni/ndk_motion.cpp
+++ b/src/android/app/src/main/jni/ndk_motion.cpp
@@ -6,7 +6,6 @@
#include "common/assert.h"
#include "common/logging/log.h"
#include "common/vector_math.h"
-#include "jni/native.h"
#include "jni/ndk_motion.h"
namespace InputManager {
diff --git a/src/android/app/src/main/res/drawable/ic_arrow_forward.xml b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml
new file mode 100644
index 000000000..3b85a3e2c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_camera.xml b/src/android/app/src/main/res/drawable/ic_camera.xml
new file mode 100644
index 000000000..7c3564224
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_camera.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_check.xml b/src/android/app/src/main/res/drawable/ic_check.xml
new file mode 100644
index 000000000..04b89abf2
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_citra_full.xml b/src/android/app/src/main/res/drawable/ic_citra_full.xml
new file mode 100644
index 000000000..99598383e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_citra_full.xml
@@ -0,0 +1,310 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_clear.xml b/src/android/app/src/main/res/drawable/ic_clear.xml
new file mode 100644
index 000000000..b6edb1d32
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_clear.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_controller.xml b/src/android/app/src/main/res/drawable/ic_controller.xml
new file mode 100644
index 000000000..329e90576
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_controller.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_controller_outline.xml b/src/android/app/src/main/res/drawable/ic_controller_outline.xml
new file mode 100644
index 000000000..4e0f053d9
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_controller_outline.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
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 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_discord.xml b/src/android/app/src/main/res/drawable/ic_discord.xml
new file mode 100644
index 000000000..7a9c6ba79
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_discord.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_github.xml b/src/android/app/src/main/res/drawable/ic_github.xml
new file mode 100644
index 000000000..c2ee43803
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_github.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_home.xml b/src/android/app/src/main/res/drawable/ic_home.xml
new file mode 100644
index 000000000..e6a510cf5
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_home.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_info_outline.xml b/src/android/app/src/main/res/drawable/ic_info_outline.xml
new file mode 100644
index 000000000..92ae0eeaf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_info_outline.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_install_driver.xml b/src/android/app/src/main/res/drawable/ic_install_driver.xml
new file mode 100644
index 000000000..8514919e0
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_install_driver.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_microphone.xml b/src/android/app/src/main/res/drawable/ic_microphone.xml
new file mode 100644
index 000000000..96b6194ce
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_microphone.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_more.xml b/src/android/app/src/main/res/drawable/ic_more.xml
new file mode 100644
index 000000000..a39afeeb2
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_more.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_notification.xml b/src/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 000000000..d3a493bfc
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 000000000..bb0726851
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_settings_outline.xml b/src/android/app/src/main/res/drawable/ic_settings_outline.xml
new file mode 100644
index 000000000..13b2745bf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_settings_outline.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_share.xml b/src/android/app/src/main/res/drawable/ic_share.xml
new file mode 100644
index 000000000..3fc2f3c99
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_system_update.xml b/src/android/app/src/main/res/drawable/ic_system_update.xml
new file mode 100644
index 000000000..e72012412
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_system_update.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_website.xml b/src/android/app/src/main/res/drawable/ic_website.xml
new file mode 100644
index 000000000..f35b84a7c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_website.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/selector_controller.xml b/src/android/app/src/main/res/drawable/selector_controller.xml
new file mode 100644
index 000000000..1905c80f1
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/selector_controller.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/android/app/src/main/res/drawable/selector_settings.xml b/src/android/app/src/main/res/drawable/selector_settings.xml
new file mode 100644
index 000000000..23748feb0
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/selector_settings.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout-w600dp/activity_main.xml b/src/android/app/src/main/res/layout-w600dp/activity_main.xml
new file mode 100644
index 000000000..74bee872e
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/activity_main.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml
new file mode 100644
index 000000000..406df9eab
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml
new file mode 100644
index 000000000..6c833f876
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml
index f668ae761..ad426457f 100644
--- a/src/android/app/src/main/res/layout/activity_main.xml
+++ b/src/android/app/src/main/res/layout/activity_main.xml
@@ -1,32 +1,58 @@
-
-
+
+
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:menu="@menu/menu_navigation"
+ app:labelVisibilityMode="selected"
+ tools:visibility="visible" />
-
+
-
+
-
-
-
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml
index 84ca54abb..6b20567cf 100644
--- a/src/android/app/src/main/res/layout/card_game.xml
+++ b/src/android/app/src/main/res/layout/card_game.xml
@@ -1,77 +1,82 @@
-
+ app:layout_constraintTop_toTopOf="parent" />
-
+ app:layout_constraintTop_toTopOf="parent">
-
+
-
+
+
+
+
+
-
+
diff --git a/src/android/app/src/main/res/layout/card_home_option.xml b/src/android/app/src/main/res/layout/card_home_option.xml
new file mode 100644
index 000000000..6457c02c8
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_home_option.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/dialog_citra_directory.xml b/src/android/app/src/main/res/layout/dialog_citra_directory.xml
index d2f251e44..6c72b1dec 100644
--- a/src/android/app/src/main/res/layout/dialog_citra_directory.xml
+++ b/src/android/app/src/main/res/layout/dialog_citra_directory.xml
@@ -1,44 +1,28 @@
-
-
-
-
-
-
+
diff --git a/src/android/app/src/main/res/layout/dialog_copy_dir.xml b/src/android/app/src/main/res/layout/dialog_copy_dir.xml
new file mode 100644
index 000000000..89b83f388
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_copy_dir.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/dialog_license.xml b/src/android/app/src/main/res/layout/dialog_license.xml
new file mode 100644
index 000000000..866857562
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_license.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 a81157a29..35462e74b 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,26 +1,24 @@
-
-
+ android:layout_margin="24dp"
+ app:trackCornerRadius="4dp" />
-
\ No newline at end of file
+ android:layout_marginLeft="24dp"
+ android:layout_marginRight="24dp"
+ android:layout_marginBottom="24dp"
+ android:gravity="end" />
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml
new file mode 100644
index 000000000..456084449
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_about.xml
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_games.xml b/src/android/app/src/main/res/layout/fragment_games.xml
new file mode 100644
index 000000000..a0568668a
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_games.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_grid.xml b/src/android/app/src/main/res/layout/fragment_grid.xml
deleted file mode 100644
index e7b2770be..000000000
--- a/src/android/app/src/main/res/layout/fragment_grid.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/android/app/src/main/res/layout/fragment_home_settings.xml b/src/android/app/src/main/res/layout/fragment_home_settings.xml
new file mode 100644
index 000000000..65a58724d
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_home_settings.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_licenses.xml b/src/android/app/src/main/res/layout/fragment_licenses.xml
new file mode 100644
index 000000000..6756458a3
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_licenses.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_search.xml b/src/android/app/src/main/res/layout/fragment_search.xml
new file mode 100644
index 000000000..be2ea4e02
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_search.xml
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_setup.xml b/src/android/app/src/main/res/layout/fragment_setup.xml
new file mode 100644
index 000000000..9499f6463
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_setup.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_system_files.xml b/src/android/app/src/main/res/layout/fragment_system_files.xml
new file mode 100644
index 000000000..734579956
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_system_files.xml
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/page_setup.xml b/src/android/app/src/main/res/layout/page_setup.xml
new file mode 100644
index 000000000..535abcf02
--- /dev/null
+++ b/src/android/app/src/main/res/layout/page_setup.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml b/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml
new file mode 100644
index 000000000..90e612a32
--- /dev/null
+++ b/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml
@@ -0,0 +1,19 @@
+
+
diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml
deleted file mode 100644
index e7fe944d6..000000000
--- a/src/android/app/src/main/res/menu/menu_game_grid.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
diff --git a/src/android/app/src/main/res/menu/menu_navigation.xml b/src/android/app/src/main/res/menu/menu_navigation.xml
new file mode 100644
index 000000000..c323ce050
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_navigation.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
new file mode 100644
index 000000000..5dfa19e1e
--- /dev/null
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/values-night/citra_colors.xml b/src/android/app/src/main/res/values-night/citra_colors.xml
index 8a379e703..98e9d50c3 100644
--- a/src/android/app/src/main/res/values-night/citra_colors.xml
+++ b/src/android/app/src/main/res/values-night/citra_colors.xml
@@ -1,33 +1,35 @@
- #F8BE00
- #3F2E00
- #451C00
- #FFDF9A
- #D7C4A0
- #3A2F15
- #52452A
- #F4E0BB
- #FFB2BC
- #5F1126
- #7D293B
- #FFD9DD
+ #B7C4FF
+ #002681
+ #0039B5
+ #DCE1FF
+ #C2C5DD
+ #2B3042
+ #424659
+ #DEE1F9
+ #E4BADA
+ #43273F
+ #5C3D56
+ #FFD7F5
#FFB4AB
#93000A
#690005
#FFDAD6
- #1E1B16
- #E9E1D9
- #1C1C1B
- #E9E1D9
- #4D4639
- #D0C5B4
- #999080
- #1E1B16
- #E9E1D9
- #785A00
- #F8BE00
- #4D4639
+ #1B1B1F
+ #E4E1E6
+ #1B1B1F
+ #E4E1E6
+ #45464F
+ #C6C5D0
+ #90909A
+ #1B1B1F
+ #E4E1E6
+ #154FE2
+ #000000
+ #B7C4FF
+ #45464F
+ #000000
diff --git a/src/android/app/src/main/res/values-v29/themes.xml b/src/android/app/src/main/res/values-v29/themes.xml
new file mode 100644
index 000000000..938634d07
--- /dev/null
+++ b/src/android/app/src/main/res/values-v29/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/values-w1000dp/integers.xml b/src/android/app/src/main/res/values-w1000dp/integers.xml
deleted file mode 100644
index 5cd4e24f3..000000000
--- a/src/android/app/src/main/res/values-w1000dp/integers.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- 4
-
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values-w1050dp/dimens.xml b/src/android/app/src/main/res/values-w1050dp/dimens.xml
deleted file mode 100644
index 92fcb2b66..000000000
--- a/src/android/app/src/main/res/values-w1050dp/dimens.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
- 96dp
-
diff --git a/src/android/app/src/main/res/values-w600dp/bools.xml b/src/android/app/src/main/res/values-w600dp/bools.xml
new file mode 100644
index 000000000..b6833a702
--- /dev/null
+++ b/src/android/app/src/main/res/values-w600dp/bools.xml
@@ -0,0 +1,4 @@
+
+
+ false
+
diff --git a/src/android/app/src/main/res/values-w600dp/dimens.xml b/src/android/app/src/main/res/values-w600dp/dimens.xml
new file mode 100644
index 000000000..128319e27
--- /dev/null
+++ b/src/android/app/src/main/res/values-w600dp/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 0dp
+ 80dp
+
diff --git a/src/android/app/src/main/res/values-w500dp/integers.xml b/src/android/app/src/main/res/values-w600dp/integers.xml
similarity index 88%
rename from src/android/app/src/main/res/values-w500dp/integers.xml
rename to src/android/app/src/main/res/values-w600dp/integers.xml
index d2955c0ae..250e291e9 100644
--- a/src/android/app/src/main/res/values-w500dp/integers.xml
+++ b/src/android/app/src/main/res/values-w600dp/integers.xml
@@ -1,4 +1,4 @@
2
-
\ No newline at end of file
+
diff --git a/src/android/app/src/main/res/values-w750dp/integers.xml b/src/android/app/src/main/res/values-w750dp/integers.xml
deleted file mode 100644
index f049d8b44..000000000
--- a/src/android/app/src/main/res/values-w750dp/integers.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- 3
-
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values-w820dp/dimens.xml b/src/android/app/src/main/res/values-w820dp/dimens.xml
deleted file mode 100644
index d27181e85..000000000
--- a/src/android/app/src/main/res/values-w820dp/dimens.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- 64dp
-
diff --git a/src/android/app/src/main/res/values/arrays.xml b/src/android/app/src/main/res/values/arrays.xml
index 444abd6cb..c7cdadd19 100644
--- a/src/android/app/src/main/res/values/arrays.xml
+++ b/src/android/app/src/main/res/values/arrays.xml
@@ -203,4 +203,34 @@
- 4
- 5
+
+
+ - @string/system_region_jpn
+ - @string/system_region_usa
+ - @string/system_region_eur
+ - @string/system_region_aus
+ - @string/system_region_chn
+ - @string/system_region_kor
+ - @string/system_region_twn
+
+
+ - 0
+ - 1
+ - 2
+ - 3
+ - 4
+ - 5
+ - 6
+
+
+
+ - @string/system_type_minimal
+ - @string/system_type_old_3ds
+ - @string/system_type_new_3ds
+
+
+ - 1
+ - 2
+ - 4
+
diff --git a/src/android/app/src/main/res/values/bools.xml b/src/android/app/src/main/res/values/bools.xml
new file mode 100644
index 000000000..e50f473fb
--- /dev/null
+++ b/src/android/app/src/main/res/values/bools.xml
@@ -0,0 +1,4 @@
+
+
+ true
+
diff --git a/src/android/app/src/main/res/values/citra_colors.xml b/src/android/app/src/main/res/values/citra_colors.xml
index f0cfd3780..4d47dcfec 100644
--- a/src/android/app/src/main/res/values/citra_colors.xml
+++ b/src/android/app/src/main/res/values/citra_colors.xml
@@ -1,35 +1,35 @@
- #FFAB03
+ #154FE2
#FFFFFF
- #FFDF9A
- #251A00
- #6B5D3F
+ #DCE1FF
+ #001551
+ #595D72
#FFFFFF
- #F4E0BB
- #241A04
- #9B4052
+ #DEE1F9
+ #161B2C
+ #75546F
#FFFFFF
- #FFD9DD
- #400013
+ #FFD7F5
+ #2C1229
#BA1A1A
#FFDAD6
#FFFFFF
#410002
- #FFFBFF
- #1E1B16
- #FFFBFF
- #1E1B16
- #EDE1CF
- #4D4639
- #7F7667
- #F7F0E7
- #33302A
- #F8BE00
+ #FEFBFF
+ #1B1B1F
+ #FEFBFF
+ #1B1B1F
+ #E2E1EC
+ #45464F
+ #767680
+ #F2F0F4
+ #303034
+ #B7C4FF
#000000
- #783E00
- #D0C5B4
+ #154FE2
+ #C6C5D0
#000000
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index f49990376..8ec30b4b2 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -4,9 +4,16 @@
18dp
4dp
+ 8dp
12dp
16dp
80dp
+ 80dp
+ 0dp
+ 72dp
+ 20dp
+ 72dp
+ 24dp
20dp
diff --git a/src/android/app/src/main/res/values/licenses.xml b/src/android/app/src/main/res/values/licenses.xml
new file mode 100644
index 000000000..f58438f66
--- /dev/null
+++ b/src/android/app/src/main/res/values/licenses.xml
@@ -0,0 +1,398 @@
+
+
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:\n\n
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.\n\n
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+ Adreno Tools
+ A library for applying rootless Adreno GPU driver modifications/replacements
+ https://github.com/bylaws/libadrenotools
+ Copyright © 2021, Billy Laws
+
+BSD 2-Clause License\n\n
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n\n
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.\n\n
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ Sirit
+ A runtime SPIR-V assembler
+ https://github.com/ReinUsesLisp/sirit
+ Copyright © 2019, sirit All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n
+* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.\n
+* Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.\n
+* Neither the name of the organization nor the
+ names of its contributors may be used to endorse or promote products
+ derived from this software without specific prior written permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ Dynarmic
+ An ARM dynamic recompiler
+ https://github.com/merryhime/dynarmic
+ Copyright © 2017 merryhime
+
+Permission to use, copy, modify, and/or distribute this software for
+any purpose with or without fee is hereby granted.\n\n
+
+THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+ cubeb
+ Cross platform audio library
+ https://github.com/mozilla/cubeb
+ Copyright © 2011 Mozilla Foundation
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.\n\n
+
+THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+ Crypto++
+ Free C++ class library of cryptographic schemes
+ https://github.com/weidai11/cryptopp
+ Compilation Copyright © 1995-2019 by Wei Dai. All rights reserved.
+
+This copyright applies only to this software distribution package
+as a compilation, and does not imply a copyright on any particular
+file in the package.\n\n
+
+All individual files in this compilation are placed in the public domain by
+Wei Dai and other contributors.\n\n
+
+I would like to thank the following authors for placing their works into
+the public domain:\n\n
+
+Joan Daemen - 3way.cpp\n
+Leonard Janke - cast.cpp, seal.cpp\n
+Steve Reid - cast.cpp\n
+Phil Karn - des.cpp\n
+Andrew M. Kuchling - md2.cpp, md4.cpp\n
+Colin Plumb - md5.cpp\n
+Seal Woods - rc6.cpp\n
+Chris Morgan - rijndael.cpp\n
+Paulo Baretto - rijndael.cpp, skipjack.cpp, square.cpp\n
+Richard De Moliner - safer.cpp\n
+Matthew Skala - twofish.cpp\n
+Kevin Springle - camellia.cpp, shacal2.cpp, ttmac.cpp, whrlpool.cpp, ripemd.cpp\n
+Ronny Van Keer - sha3.cpp\n
+Aumasson, Neves, Wilcox-O\'Hearn and Winnerlein - blake2.cpp, blake2b_simd.cpp, blake2s_simd.cpp\n
+Aaram Yun - aria.cpp, aria_simd.cpp\n
+Han Lulu, Markku-Juhani O. Saarinen - sm4.cpp sm4_simd.cpp\n
+Daniel J. Bernstein, Jack Lloyd - chacha.cpp, chacha_simd.cpp, chacha_avx.cpp\n
+Andrew Moon - ed25519, x25519, donna_32.cpp, donna_64.cpp, donna_sse.cpp\n\n
+
+The Crypto++ Library uses portions of Andy Polyakov\'s CRYPTOGAMS on Linux
+for 32-bit ARM with files aes_armv4.S, sha1_armv4.S and sha256_armv4.S.
+CRYPTOGAMS is dual licensed with a permissive BSD-style license. The
+CRYPTOGAMS license is reproduced below. You can disable Cryptogams code by
+undefining the relevant macros in config_asm.h.\n\n
+
+The Crypto++ Library uses portions of Jack Lloyd\'s Botan for ChaCha SSE2 and
+AVX. Botan placed the code in public domain for Crypto++ to use.\n\n
+
+The Crypto++ Library (as a compilation) is currently licensed under the Boost
+Software License 1.0 (http://www.boost.org/users/license.html).\n\n
+
+Boost Software License - Version 1.0 - August 17th, 2003\n\n
+
+Permission is hereby granted, free of charge, to any person or organization
+obtaining a copy of the software and accompanying documentation covered by
+this license (the "Software") to use, reproduce, display, distribute,
+execute, and transmit the Software, and to prepare derivative works of the
+Software, and to permit third-parties to whom the Software is furnished to
+do so, all subject to the following:\n\n
+
+The copyright notices in the Software and this entire statement, including
+the above license grant, this restriction and the following disclaimer,
+must be included in all copies of the Software, in whole or in part, and
+all derivative works of the Software, unless such copies or derivative
+works are solely in the form of machine-executable object code generated by
+a source language processor.\n\n
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.\n\n
+
+CRYPTOGAMS License\n\n
+
+Copyright © 2006-2017, CRYPTOGAMS by appro\@openssl.org\n
+All rights reserved.\n\n
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:\n\n
+
+* Redistributions of source code must retain copyright notices,
+ this list of conditions and the following disclaimer.\n
+* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials
+ provided with the distribution.\n
+* Neither the name of the CRYPTOGAMS nor the names of its copyright
+ holder and contributors may be used to endorse or promote products
+ derived from this software without specific prior written permission.
+
+ fmt
+ A modern formatting library
+ https://github.com/fmtlib/fmt
+ Copyright © 2012 - present, Victor Zverovich and {fmt} contributors
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:\n\n
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.\n\n
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n
+
+--- Optional exception to the license ---\n\n
+
+As an exception, if, as a result of your compiling your source code, portions
+of this Software are embedded into a machine-executable object form of such
+source code, you may redistribute such embedded portions in such object form
+without including the above copyright and permission notices.
+
+ boost
+ Free peer-reviewed portable C++ source libraries
+ https://www.boost.org/
+
+Boost Software License - Version 1.0 - August 17th, 2003\n\n
+
+Permission is hereby granted, free of charge, to any person or organization
+obtaining a copy of the software and accompanying documentation covered by
+this license (the "Software") to use, reproduce, display, distribute,
+execute, and transmit the Software, and to prepare derivative works of the
+Software, and to permit third-parties to whom the Software is furnished to
+do so, all subject to the following:\n\n
+
+The copyright notices in the Software and this entire statement, including
+the above license grant, this restriction and the following disclaimer,
+must be included in all copies of the Software, in whole or in part, and
+all derivative works of the Software, unless such copies or derivative
+works are solely in the form of machine-executable object code generated by
+a source language processor.\n\n
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+ nihstro
+ 3DS shader assembler and disassembler
+ https://github.com/neobrain/nihstro
+ Copyright © 2014 Tony Wasserka, All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n\n
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.\n
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.\n
+ * Neither the name of the owner nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ cpp-httplib
+ A C++ header-only HTTP/HTTPS server and client library
+ https://github.com/yhirose/cpp-httplib
+ Copyright © 2017 yhirose
+ Teakra
+ DSi/3DS DSP emulator, disassembler, assembler, and tester
+ https://github.com/wwylele/teakra
+ Copyright © 2018 Weiyi Wang
+ ENet
+ Reliable UDP networking library
+ https://github.com/lsalzman/enet
+ Copyright © 2002-2020 Lee Salzman
+ glad
+ Multi-Language Vulkan/GL/GLES/EGL/GLX/WGL Loader-Generator based on the official specs
+ https://github.com/Dav1dde/glad
+ Copyright © 2013-2022 David Herberth
+ Glslang
+ Khronos-reference front end for GLSL/ESSL, partial front end for HLSL, and a SPIR-V generator
+ https://github.com/KhronosGroup/glslang
+ https://github.com/KhronosGroup/glslang/blob/main/LICENSE.txt
+ OpenAL Soft
+ Software implementation of the OpenAL 3D audio API
+ https://github.com/kcat/openal-soft
+
+Copyright (c) 2015, Archontis Politis\n
+Copyright (c) 2019, Anis A. Hireche\n
+Copyright (c) 2019, Christopher Robinson\n
+All rights reserved
+
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n\n
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.\n\n
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.\n\n
+
+* Neither the name of Spherical-Harmonic-Transform nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ SDL
+ Cross-platform development library designed to provide low level access to audio, keyboard, mouse, joystick, and graphics hardware via OpenGL and Direct3D
+ https://github.com/libsdl-org/SDL
+ Copyright (C) 1997-2023 Sam Lantinga slouken@libsdl.org
+
+This software is provided \'as-is\', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.\n\n
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:\n\n
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.\n
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.\n
+3. This notice may not be removed or altered from any source distribution.
+
+ Vulkan Memory Allocator
+ Easy to integrate Vulkan memory allocation library
+ https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator
+ Copyright (c) 2017-2022 Advanced Micro Devices, Inc. All rights reserved.
+ Zstandard
+ Fast real-time compression algorithm
+ https://github.com/facebook/zstd
+ Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:\n\n
+
+ * Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.\n\n
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.\n\n
+
+ * Neither the name Facebook, nor Meta, nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 79af0abfa..9c646b035 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -10,6 +10,97 @@
Citra is Running
Next, you will need to select a Game Folder. Citra will display all of the 3DS ROMs inside of the selected folder in the app.\n\nCIA ROMs, updates and DLC will need to be installed separately by clicking on the folder icon and selecting Install CIA.
+
+ Settings
+ Options
+ Search
+ Games
+ Configure emulator settings
+ Install CIA file
+ Install games, updates or DLC
+ Share Log
+ Share Citra\'s log file to debug issues
+ GPU Driver Manager
+ Install GPU driver
+ Install alternative drivers for potentially better performance or accuracy
+ Driver already installed
+ Custom drivers not supported
+ Custom driver loading isn\'t currently supported for this device.\nCheck this option again in the future to see if support was added!
+ No log file found
+ Select Games Folder
+ Allows Citra to populate the games list
+ About
+ An open-source 3DS emulator
+ Build version, credits, and more
+ Games directory selected
+ Changes the files that Citra uses to load games
+ Modify the look of the app
+ Install CIA
+
+
+ Select GPU driver
+ Would you like to replace your current GPU driver?
+ Install
+ Default
+ Installed %s
+ Using default GPU driver
+ Invalid driver selected, using system default!
+ System GPU driver
+ Installing driver…
+
+
+ Copied to clipboard
+ Contributors
+ Made with \u2764 from the Citra team
+ https://github.com/citra-emu/citra/graphs/contributors
+ Projects that make Citra for Android possible
+ Build
+ Licenses
+ https://discord.gg/FAXfZV9
+ https://citra-emu.org/
+ https://github.com/citra-emu
+
+
+ Welcome!
+ Learn how to setup <b>Citra</b> and jump into emulation.
+ Get started
+ Complete!
+ Games
+ Select your <b>Games</b> folder with the button below.
+ Done
+ You\'re all set.\nEnjoy your games!
+ Continue
+ Add Games
+ Notifications
+ Grant the notification permission with the button below.
+ Grant permission
+ Skip granting the notification permission?
+ Citra won\'t be able to notify you of important information.
+ Camera
+ Grant the camera permission below to emulate the 3DS camera.
+ Microphone
+ Grant the microphone permission below to emulate the 3DS microphone.
+ Permission denied
+ Skip selecting games folder?
+ Games won\'t be displayed in the Games list if a folder isn\'t selected.
+ https://citra-emu.org/wiki/dumping-game-cartridges/
+ Help
+ Skip
+ Cancel
+ Select User Folder
+ user data directory with the button below.]]>
+ Select
+ You can\'t skip this step
+ This step is required to allow Citra to work. Please select a directory and then you can continue.
+ https://github.com/citra-emu/citra/wiki/Citra-Android-user-data-and-storage
+
+
+ Search and Filter Games
+ Search Games
+ Recently Played
+ Recently Added
+ Installed
+
Circle Pad
C-Stick
@@ -34,13 +125,43 @@
This control must be bound to a gamepad analog stick or D-pad axis!
This control must be bound to a gamepad button!
+
+ System Files
+ Download system files to get Mii files, boot the HOME menu, and more
+ Download System Files
+ Boot the HOME Menu
+ System Type
+ Download
+ Start
+ Citra is missing keys to download system files.
+ How to get keys?]]>
+ Show HOME menu apps in games list
+ Run System Setup when the HOME Menu is launched
+ JPN
+ USA
+ EUR
+ AUS
+ CHN
+ KOR
+ TWN
+ Minimal
+ Old 3DS
+ New 3DS
+ Downloading Files…
+ Please do not close the app.
+ Download Failed
+ Please make sure you are connected to the internet and try again.
+ Download Complete!
+ Download Cancelled
+ Please restart the download to prevent issues with having incomplete system files.
+ Cancelling…
+ HOME Menu
+ System Files Warning
+ Due to how slow Android\'s storage access framework is for accessing Citra\'s files, downloading multiple versions of system files can dramatically slow down loading for games, save states, and the games list. Only download the files that you require to avoid any issues with loading speeds.
+
Buttons
-
- Change Theme (Light, Dark)
- Theme will update when exiting Settings
-
CPU JIT
Uses the Just-in-Time (JIT) compiler for CPU emulation. When enabled, game performance will be significantly improved.
@@ -124,13 +245,6 @@
Async Custom Texture Loading
Load custom textures asynchronously with background threads to reduce loading stutter.
-
- Premium
- Upgrade to Premium and support Citra!
- With Premium, you will support the developers to continue improving Citra, and gain access to these exclusive features!
- Welcome to Premium.
- Thank you for your support!
-
Audio Stretching
Stretches audio to reduce stuttering. When enabled, increases audio latency and slightly reduces performance.
@@ -143,14 +257,21 @@
Saved settings for %1$s
Error saving %1$s.ini: %2$s
Loading…
-
-
- Settings
+ Next
+ Back
+ Learn More
+ Close
+ Reset to default
+ game cartidges or installed titles.]]>
+ Default
+ None
+ Auto
+ Off
+ Install
+ Delete
- Select Citra User Folder
Select Game Folder
- Install CIA
Properties
@@ -158,7 +279,6 @@
Settings
- Premium
General
System
Camera
@@ -170,6 +290,7 @@
Your ROM is Encrypted
Invalid ROM format
+ ROM file does not exist
Press Back to access the menu.
@@ -213,10 +334,11 @@
No files were found or no game directory has been selected yet.
Do not show this again
- Searching directory: %s
+ Searching directory: %s
Move Data
+ Moving Data…
Copy file: %s
- Free space: %.2f GB
+ Copy Complete
Savestates
Warning: Savestates are NOT a replacement for in-game saves, and are not meant to be reliable.\n\nUse at your own risk!
diff --git a/src/android/app/src/main/res/values/styles.xml b/src/android/app/src/main/res/values/styles.xml
index 2a0ca8a78..5373bd8d9 100644
--- a/src/android/app/src/main/res/values/styles.xml
+++ b/src/android/app/src/main/res/values/styles.xml
@@ -1,40 +1,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/android/app/src/main/res/values/themes.xml b/src/android/app/src/main/res/values/themes.xml
index dd66d30eb..d7f4eed31 100644
--- a/src/android/app/src/main/res/values/themes.xml
+++ b/src/android/app/src/main/res/values/themes.xml
@@ -7,8 +7,7 @@
- @style/Theme.Citra.Main
-
-
+
+
+
diff --git a/src/android/build.gradle.kts b/src/android/build.gradle.kts
index d2bde7bab..05e927ecf 100644
--- a/src/android/build.gradle.kts
+++ b/src/android/build.gradle.kts
@@ -4,11 +4,21 @@
// 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
+ id("org.jetbrains.kotlin.plugin.serialization") version "1.8.21"
}
tasks.register("clean").configure {
delete(rootProject.buildDir)
}
+
+buildscript {
+ repositories {
+ google()
+ }
+ dependencies {
+ classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.7.5")
+ }
+}
diff --git a/src/android/settings.gradle.kts b/src/android/settings.gradle.kts
index e5b8fcabe..fe6229092 100644
--- a/src/android/settings.gradle.kts
+++ b/src/android/settings.gradle.kts
@@ -16,7 +16,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
- jcenter()
}
}
diff --git a/src/core/system_titles.h b/src/core/system_titles.h
index 6a0b343e0..a8b640f75 100644
--- a/src/core/system_titles.h
+++ b/src/core/system_titles.h
@@ -4,6 +4,7 @@
#pragma once
+#include
#include
#include "common/common_types.h"
diff --git a/src/video_core/renderer_vulkan/vk_instance.cpp b/src/video_core/renderer_vulkan/vk_instance.cpp
index 985c547fe..519138e2f 100644
--- a/src/video_core/renderer_vulkan/vk_instance.cpp
+++ b/src/video_core/renderer_vulkan/vk_instance.cpp
@@ -440,13 +440,15 @@ bool Instance::CreateDevice() {
const bool is_moltenvk = driver_id == vk::DriverIdKHR::eMoltenvk;
const bool is_arm = driver_id == vk::DriverIdKHR::eArmProprietary;
const bool is_qualcomm = driver_id == vk::DriverIdKHR::eQualcommProprietary;
+ const bool is_turnip = driver_id == vk::DriverIdKHR::eMesaTurnip;
add_extension(VK_KHR_SWAPCHAIN_EXTENSION_NAME);
image_format_list = add_extension(VK_KHR_IMAGE_FORMAT_LIST_EXTENSION_NAME);
shader_stencil_export = add_extension(VK_EXT_SHADER_STENCIL_EXPORT_EXTENSION_NAME);
tooling_info = add_extension(VK_EXT_TOOLING_INFO_EXTENSION_NAME);
- const bool has_timeline_semaphores = add_extension(
- VK_KHR_TIMELINE_SEMAPHORE_EXTENSION_NAME, is_qualcomm, "it is broken on Qualcomm drivers");
+ const bool has_timeline_semaphores =
+ add_extension(VK_KHR_TIMELINE_SEMAPHORE_EXTENSION_NAME, is_qualcomm || is_turnip,
+ "it is broken on Qualcomm drivers");
const bool has_portability_subset = add_extension(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME);
const bool has_extended_dynamic_state =
add_extension(VK_EXT_EXTENDED_DYNAMIC_STATE_EXTENSION_NAME, is_arm || is_qualcomm,
diff --git a/src/video_core/renderer_vulkan/vk_platform.cpp b/src/video_core/renderer_vulkan/vk_platform.cpp
index 1f6fcfc1b..a6be64257 100644
--- a/src/video_core/renderer_vulkan/vk_platform.cpp
+++ b/src/video_core/renderer_vulkan/vk_platform.cpp
@@ -304,7 +304,7 @@ vk::UniqueInstance CreateInstance(const Common::DynamicLibrary& library,
.applicationVersion = VK_MAKE_VERSION(1, 0, 0),
.pEngineName = "Citra Vulkan",
.engineVersion = VK_MAKE_VERSION(1, 0, 0),
- .apiVersion = available_version,
+ .apiVersion = VK_API_VERSION_1_3,
};
boost::container::static_vector layers;