1
0
Fork 0

Android UI Overhaul Part 1 (#7108)

* android: Android 14 support

* android: New home UI flow

Port of the yuzu-android home UI with a few Citra specific tweaks.

A few important things to note
- New and existing Citra users will be guided through the new setup flow
- Existing game directory location is discarded and will have to be reselected
- Protections around making sure the user has selected a user directory were reworked to fit this new UI. I removed async directory init and DirectoryStateReceivers and check during MainActivity's onResume callback.
- Removed Citra premium. The light/dark theme is now available for everyone.

* android: New blue app theme

* android: Extend UI into status/navigation bar area

* android: Remove yellow theme specific styles

* android: Disable status/navigation bar contrast enforcement

We handle it ourselves so there's no need to use a contrasty background on the system bars

* android: GPU Driver Manager

Includes a rewrite of FileUtil with some helper functions for the manager

* android: Rework NativeLibrary in Kotlin

Besides the rewrite this cleans up the alert dialogs that are used for system errors. Generally removes unused JNI code and makes things a little more consistent.

* android: Home menu support + downloader

* android: Enable minify and resource shrinking

* android: Remove premium page and expose texture filtering modes

* android: Update AGP to 8.1.2

* android: Don't display emulation in cutout area

We don't currently handle the notch properly in the emulation fragment so just don't render under it for now.

* android: native.cpp ClangFormat fixes

* core: SystemTitles: Include std::optional

Without it, the android build would fail

* vk: android: Properly override GetDriverLibrary

* vk_instance: Blacklist timeline semaphore ext on turnip

* vk_platform: Hardcode apiVersion to VK_API_VERSION_1_3

* android: native: Use const where applicable

* android: native: Array pointer access style fix

* android: Share relevant log

Shares the old log if it exists and you haven't booted a game yet and shares the current log if you have booted a game.

* android: Apply dark theme color for software keyboard text

---------

Co-authored-by: GPUCode <geoster3d@gmail.com>
This commit is contained in:
Charles Lombardo 2023-11-10 18:16:54 -05:00 committed by GitHub
parent 80ac6c03b5
commit fa08df21a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
182 changed files with 10511 additions and 5183 deletions

View File

@ -2,15 +2,18 @@
// Licensed under GPLv2 or any later version // Licensed under GPLv2 or any later version
// Refer to the license.txt file included. // Refer to the license.txt file included.
import android.databinding.tool.ext.capitalizeUS
import de.undercouch.gradle.tasks.download.Download
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("de.undercouch.download") version "5.5.0" 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. * 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 * This lets us upload a new build at most every 10 seconds for the
@ -25,7 +28,7 @@ val downloadedJniLibsPath = "${buildDir}/downloadedJniLibs"
android { android {
namespace = "org.citra.citra_emu" namespace = "org.citra.citra_emu"
compileSdkVersion = "android-33" compileSdkVersion = "android-34"
ndkVersion = "25.2.9519653" ndkVersion = "25.2.9519653"
compileOptions { compileOptions {
@ -37,6 +40,11 @@ android {
jvmTarget = "17" jvmTarget = "17"
} }
packaging {
// This is necessary for libadrenotools custom driver loading
jniLibs.useLegacyPackaging = true
}
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
} }
@ -51,7 +59,7 @@ android {
// TODO If this is ever modified, change application_id in strings.xml // TODO If this is ever modified, change application_id in strings.xml
applicationId = "org.citra.citra_emu" applicationId = "org.citra.citra_emu"
minSdk = 28 minSdk = 28
targetSdk = 33 targetSdk = 34
versionCode = autoVersion versionCode = autoVersion
versionName = getGitVersion() versionName = getGitVersion()
@ -69,6 +77,9 @@ android {
) )
} }
} }
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
} }
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE") val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
@ -92,6 +103,12 @@ android {
} else { } else {
signingConfigs.getByName("debug") signingConfigs.getByName("debug")
} }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
} }
// builds a release build that doesn't need signing // builds a release build that doesn't need signing
@ -101,9 +118,15 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-debug" versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
isDebuggable = true isDebuggable = true
isJniDebuggable = true isJniDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
isDefault = true
} }
// Signed by debug key disallowing distribution on Play Store. // Signed by debug key disallowing distribution on Play Store.
@ -145,8 +168,9 @@ android {
} }
dependencies { dependencies {
implementation("androidx.activity:activity-ktx:1.7.2") implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.fragment:fragment-ktx:1.6.0") 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.appcompat:appcompat:1.6.1")
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.0.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
@ -158,15 +182,14 @@ dependencies {
// For loading huge screenshots from the disk. // For loading huge screenshots from the disk.
implementation("com.squareup.picasso:picasso:2.71828") 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("org.ini4j:ini4j:0.5.4")
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.5")
// Please don't upgrade the billing library as the newer version is not GPL-compatible implementation("androidx.navigation:navigation-ui-ktx:2.7.5")
implementation("com.android.billingclient:billing:2.0.3") 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. // Download Vulkan Validation Layers from the KhronosGroup GitHub.
@ -216,6 +239,34 @@ fun getGitVersion(): String {
return versionName 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 { android.applicationVariants.configureEach {
val variant = this val variant = this
val capitalizedName = variant.name.capitalizeUS() val capitalizedName = variant.name.capitalizeUS()

View File

@ -1,21 +1,25 @@
# Add project specific ProGuard rules here. # Copyright 2023 Citra Emulator Project
# You can control the set of applied configuration files using the # Licensed under GPLv2 or any later version
# proguardFiles setting in build.gradle. # Refer to the license.txt file included.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following # To get usable stack traces
# and specify the fully qualified class name to the JavaScript interface -dontobfuscate
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for # Prevents crashing when using Wini
# debugging stack traces. -keep class org.ini4j.spi.IniParser
#-keepattributes SourceFile,LineNumberTable -keep class org.ini4j.spi.IniBuilder
-keep class org.ini4j.spi.IniFormatter
# If you keep the line number information, uncomment this to # Suppress warnings for R8
# hide the original source file name. -dontwarn org.bouncycastle.jsse.BCSSLParameters
#-renamesourcefileattribute SourceFile -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

View File

@ -29,6 +29,7 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
@ -44,8 +45,7 @@
<activity <activity
android:name="org.citra.citra_emu.ui.main.MainActivity" android:name="org.citra.citra_emu.ui.main.MainActivity"
android:theme="@style/Theme.Citra.Splash.Main" android:theme="@style/Theme.Citra.Splash.Main"
android:exported="true" android:exported="true">
android:resizeableActivity="false">
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. --> <!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
<intent-filter> <intent-filter>
@ -68,21 +68,15 @@
android:theme="@style/Theme.Citra.Main" android:theme="@style/Theme.Citra.Main"
android:launchMode="singleTop"/> android:launchMode="singleTop"/>
<service android:name="org.citra.citra_emu.utils.ForegroundService"/> <service android:name="org.citra.citra_emu.utils.ForegroundService" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Keep emulation running in background"/>
</service>
<activity <activity
android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity" android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
android:exported="false" android:exported="false"
android:theme="@style/Theme.Citra.Main" android:theme="@style/Theme.Citra.Main"
android:label="@string/cheats"/> android:label="@string/cheats"/>
<provider
android:name="org.citra.citra_emu.model.GameProvider"
android:authorities="${applicationId}.provider"
android:enabled="true"
android:exported="false">
</provider>
</application> </application>
</manifest> </manifest>

View File

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

View File

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

View File

@ -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<EmulationActivity> 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 <a href=\"https://citra-emu.org/wiki/dumping-game-cartridges/\">game cartidges</a> or <a href=\"https://citra-emu.org/wiki/dumping-installed-titles/\">installed titles</a>.", 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);
}
}

View File

@ -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<EmulationActivity?>(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<String?>
// 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<View>(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<SaveStateInfo>?
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<String?> =
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
}
}

View File

@ -18,6 +18,7 @@ import android.view.MenuItem;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.SubMenu; import android.view.SubMenu;
import android.view.View; import android.view.View;
import android.view.WindowManager;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; 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.FileBrowserHelper;
import org.citra.citra_emu.utils.FileUtil; import org.citra.citra_emu.utils.FileUtil;
import org.citra.citra_emu.utils.ForegroundService; import org.citra.citra_emu.utils.ForegroundService;
import org.citra.citra_emu.utils.Log;
import org.citra.citra_emu.utils.ThemeUtil; import org.citra.citra_emu.utils.ThemeUtil;
import java.io.File; import java.io.File;
@ -169,8 +171,8 @@ public final class EmulationActivity extends AppCompatActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
ThemeUtil.applyTheme(this); Log.gameLaunched = true;
ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
if (savedInstanceState == null) { if (savedInstanceState == null) {
@ -210,7 +212,7 @@ public final class EmulationActivity extends AppCompatActivity {
startForegroundService(foregroundService); startForegroundService(foregroundService);
// Override Citra core INI with the one set by our in game menu // 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()); getWindowManager().getDefaultDisplay().getRotation());
} }
@ -224,15 +226,12 @@ public final class EmulationActivity extends AppCompatActivity {
protected void restoreState(Bundle savedInstanceState) { protected void restoreState(Bundle savedInstanceState) {
mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME);
mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE);
// If an alert prompt was in progress when state was restored, retry displaying it
NativeLibrary.retryDisplayAlertPrompt();
} }
@Override @Override
public void onRestart() { public void onRestart() {
super.onRestart(); super.onRestart();
NativeLibrary.ReloadCameraDevices(); NativeLibrary.INSTANCE.reloadCameraDevices();
} }
@Override @Override
@ -257,7 +256,7 @@ public final class EmulationActivity extends AppCompatActivity {
.setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
.show(); .show();
} }
NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); NativeLibrary.INSTANCE.cameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
break; break;
case NativeLibrary.REQUEST_CODE_NATIVE_MIC: case NativeLibrary.REQUEST_CODE_NATIVE_MIC:
if (grantResults[0] != PackageManager.PERMISSION_GRANTED && if (grantResults[0] != PackageManager.PERMISSION_GRANTED &&
@ -268,7 +267,7 @@ public final class EmulationActivity extends AppCompatActivity {
.setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
.show(); .show();
} }
NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); NativeLibrary.INSTANCE.micPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
break; break;
default: default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@ -281,6 +280,10 @@ public final class EmulationActivity extends AppCompatActivity {
} }
private void enableFullscreenImmersive() { 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( getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
@ -323,7 +326,7 @@ public final class EmulationActivity extends AppCompatActivity {
} }
private void DisplaySavestateWarning() { private void DisplaySavestateWarning() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
if (preferences.getBoolean("savestateWarningShown", false)) { if (preferences.getBoolean("savestateWarningShown", false)) {
return; return;
} }
@ -350,7 +353,7 @@ public final class EmulationActivity extends AppCompatActivity {
} }
private void updateSavestateMenuOptions(Menu menu) { private void updateSavestateMenuOptions(Menu menu) {
final NativeLibrary.SavestateInfo[] savestates = NativeLibrary.GetSavestateInfo(); final NativeLibrary.SaveStateInfo[] savestates = NativeLibrary.INSTANCE.getSavestateInfo();
if (savestates == null) { if (savestates == null) {
menu.findItem(R.id.menu_emulation_save_state).setVisible(false); menu.findItem(R.id.menu_emulation_save_state).setVisible(false);
menu.findItem(R.id.menu_emulation_load_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); final String text = getString(R.string.emulation_empty_state_slot, slot);
saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> { saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> {
DisplaySavestateWarning(); DisplaySavestateWarning();
NativeLibrary.SaveState(slot); NativeLibrary.INSTANCE.saveState(slot);
return true; return true;
}); });
loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> { loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> {
NativeLibrary.LoadState(slot); NativeLibrary.INSTANCE.loadState(slot);
return true; return true;
}); });
} }
for (final NativeLibrary.SavestateInfo info : savestates) { for (final NativeLibrary.SaveStateInfo info : savestates) {
final String text = getString(R.string.emulation_occupied_state_slot, info.slot, info.time); final String text = getString(R.string.emulation_occupied_state_slot, info.getSlot(), info.getTime());
saveStateMenu.getItem(info.slot - 1).setTitle(text); saveStateMenu.getItem(info.getSlot() - 1).setTitle(text);
loadStateMenu.getItem(info.slot - 1).setTitle(text).setEnabled(true); loadStateMenu.getItem(info.getSlot() - 1).setTitle(text).setEnabled(true);
} }
} }
@ -441,7 +444,7 @@ public final class EmulationActivity extends AppCompatActivity {
EmulationMenuSettings.setSwapScreens(isEnabled); EmulationMenuSettings.setSwapScreens(isEnabled);
item.setChecked(isEnabled); item.setChecked(isEnabled);
NativeLibrary.SwapScreens(isEnabled, getWindowManager().getDefaultDisplay() NativeLibrary.INSTANCE.swapScreens(isEnabled, getWindowManager().getDefaultDisplay()
.getRotation()); .getRotation());
break; break;
} }
@ -491,11 +494,11 @@ public final class EmulationActivity extends AppCompatActivity {
break; break;
case MENU_ACTION_OPEN_CHEATS: case MENU_ACTION_OPEN_CHEATS:
CheatsActivity.launch(this, NativeLibrary.GetRunningTitleId()); CheatsActivity.launch(this, NativeLibrary.INSTANCE.getRunningTitleId());
break; break;
case MENU_ACTION_CLOSE_GAME: case MENU_ACTION_CLOSE_GAME:
NativeLibrary.PauseEmulation(); NativeLibrary.INSTANCE.pauseEmulation();
new MaterialAlertDialogBuilder(this) new MaterialAlertDialogBuilder(this)
.setTitle(R.string.emulation_close_game) .setTitle(R.string.emulation_close_game)
.setMessage(R.string.emulation_close_game_message) .setMessage(R.string.emulation_close_game_message)
@ -504,8 +507,8 @@ public final class EmulationActivity extends AppCompatActivity {
mEmulationFragment.stopEmulation(); mEmulationFragment.stopEmulation();
finish(); finish();
}) })
.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.UnPauseEmulation()) .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.INSTANCE.unPauseEmulation())
.setOnCancelListener(dialogInterface -> NativeLibrary.UnPauseEmulation()) .setOnCancelListener(dialogInterface -> NativeLibrary.INSTANCE.unPauseEmulation())
.show(); .show();
break; break;
} }
@ -515,7 +518,7 @@ public final class EmulationActivity extends AppCompatActivity {
private void changeScreenOrientation(int layoutOption, MenuItem item) { private void changeScreenOrientation(int layoutOption, MenuItem item) {
item.setChecked(true); item.setChecked(true);
NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() NativeLibrary.INSTANCE.notifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
.getRotation()); .getRotation());
EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); EmulationMenuSettings.setLandscapeScreenLayout(layoutOption);
} }
@ -558,7 +561,7 @@ public final class EmulationActivity extends AppCompatActivity {
return false; return false;
} }
return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action); return NativeLibrary.INSTANCE.onGamePadEvent(input.getDescriptor(), button, action);
} }
@Override @Override
@ -570,7 +573,7 @@ public final class EmulationActivity extends AppCompatActivity {
} }
private void onAmiiboSelected(String selectedFile) { private void onAmiiboSelected(String selectedFile) {
boolean success = NativeLibrary.LoadAmiibo(selectedFile); boolean success = NativeLibrary.INSTANCE.loadAmiibo(selectedFile);
if (!success) { if (!success) {
new MaterialAlertDialogBuilder(this) new MaterialAlertDialogBuilder(this)
@ -582,7 +585,7 @@ public final class EmulationActivity extends AppCompatActivity {
} }
private void RemoveAmiibo() { private void RemoveAmiibo() {
NativeLibrary.RemoveAmiibo(); NativeLibrary.INSTANCE.removeAmiibo();
} }
private void toggleControls() { private void toggleControls() {
@ -725,47 +728,47 @@ public final class EmulationActivity extends AppCompatActivity {
} }
// Circle-Pad and C-Stick status // Circle-Pad and C-Stick status
NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); NativeLibrary.INSTANCE.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_C, axisValuesCStick[0], axisValuesCStick[1]);
// Triggers L/R and ZL/ZR // Triggers L/R and ZL/ZR
if (isTriggerPressedLMapped) { 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) { 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) { 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) { 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 // Work-around to allow D-pad axis to be bound to emulated buttons
if (axisValuesDPad[0] == 0.f) { if (axisValuesDPad[0] == 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); NativeLibrary.INSTANCE.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_RIGHT, NativeLibrary.ButtonState.RELEASED);
} }
if (axisValuesDPad[0] < 0.f) { if (axisValuesDPad[0] < 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); NativeLibrary.INSTANCE.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_RIGHT, NativeLibrary.ButtonState.RELEASED);
} }
if (axisValuesDPad[0] > 0.f) { if (axisValuesDPad[0] > 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); NativeLibrary.INSTANCE.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_RIGHT, NativeLibrary.ButtonState.PRESSED);
} }
if (axisValuesDPad[1] == 0.f) { if (axisValuesDPad[1] == 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); NativeLibrary.INSTANCE.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_DOWN, NativeLibrary.ButtonState.RELEASED);
} }
if (axisValuesDPad[1] < 0.f) { if (axisValuesDPad[1] < 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); NativeLibrary.INSTANCE.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_DOWN, NativeLibrary.ButtonState.RELEASED);
} }
if (axisValuesDPad[1] > 0.f) { if (axisValuesDPad[1] > 0.f) {
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); NativeLibrary.INSTANCE.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_DOWN, NativeLibrary.ButtonState.PRESSED);
} }
return true; return true;

View File

@ -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<Pair<Uri, GpuDriverMetadata>, DriverAdapter.DriverViewHolder>(
AsyncDifferConfig.Builder(DiffCallback()).build()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder {
val binding =
CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return DriverViewHolder(binding)
}
override fun getItemCount(): Int = currentList.size
override fun onBindViewHolder(holder: DriverViewHolder, position: Int) =
holder.bind(currentList[position])
private fun onSelectDriver(position: Int) {
driverViewModel.setSelectedDriverIndex(position)
notifyItemChanged(driverViewModel.previouslySelectedDriver)
notifyItemChanged(driverViewModel.selectedDriver)
}
private fun onDeleteDriver(driverData: Pair<Uri, GpuDriverMetadata>, position: Int) {
if (driverViewModel.selectedDriver > position) {
driverViewModel.setSelectedDriverIndex(driverViewModel.selectedDriver - 1)
}
if (GpuDriverHelper.customDriverData == driverData.second) {
driverViewModel.setSelectedDriverIndex(0)
}
driverViewModel.driversToDelete.add(driverData.first)
driverViewModel.removeDriver(driverData)
notifyItemRemoved(position)
notifyItemChanged(driverViewModel.selectedDriver)
}
inner class DriverViewHolder(val binding: CardDriverOptionBinding) :
RecyclerView.ViewHolder(binding.root) {
private lateinit var driverData: Pair<Uri, GpuDriverMetadata>
fun bind(driverData: Pair<Uri, GpuDriverMetadata>) {
this.driverData = driverData
val driver = driverData.second
binding.apply {
radioButton.isChecked = driverViewModel.selectedDriver == bindingAdapterPosition
root.setOnClickListener {
onSelectDriver(bindingAdapterPosition)
}
buttonDelete.setOnClickListener {
onDeleteDriver(driverData, bindingAdapterPosition)
}
// Delay marquee by 3s
title.postDelayed(
{
title.isSelected = true
title.ellipsize = TextUtils.TruncateAt.MARQUEE
version.isSelected = true
version.ellipsize = TextUtils.TruncateAt.MARQUEE
description.isSelected = true
description.ellipsize = TextUtils.TruncateAt.MARQUEE
},
3000
)
if (driver.name == null) {
title.setText(R.string.system_gpu_driver)
description.text = ""
version.text = ""
version.visibility = View.GONE
description.visibility = View.GONE
buttonDelete.visibility = View.GONE
} else {
title.text = driver.name
version.text = driver.version
description.text = driver.description
version.visibility = View.VISIBLE
description.visibility = View.VISIBLE
buttonDelete.visibility = View.VISIBLE
}
}
}
}
private class DiffCallback : DiffUtil.ItemCallback<Pair<Uri, GpuDriverMetadata>>() {
override fun areItemsTheSame(
oldItem: Pair<Uri, GpuDriverMetadata>,
newItem: Pair<Uri, GpuDriverMetadata>
): Boolean {
return oldItem.first == newItem.first
}
override fun areContentsTheSame(
oldItem: Pair<Uri, GpuDriverMetadata>,
newItem: Pair<Uri, GpuDriverMetadata>
): Boolean {
return oldItem.second == newItem.second
}
}
}

View File

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

View File

@ -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<Game, GameViewHolder>(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<Game>() {
override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem.titleId == newItem.titleId
}
override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem == newItem
}
}
}

View File

@ -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<HomeSetting>
) : RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(), 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
}
}
}
}

View File

@ -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<License>) :
RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(),
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)
}
}
}

View File

@ -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<SetupPage>) :
RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() {
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)
}
}
}

View File

@ -18,13 +18,16 @@ import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Objects; import java.util.Objects;
import androidx.annotation.Keep;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@Keep
public final class MiiSelector { public final class MiiSelector {
@Keep
public static class MiiSelectorConfig implements java.io.Serializable { public static class MiiSelectorConfig implements java.io.Serializable {
public boolean enable_cancel_button; public boolean enable_cancel_button;
public String title; public String title;

View File

@ -7,13 +7,17 @@ package org.citra.citra_emu.applets;
import android.app.Activity; import android.app.Activity;
import android.app.Dialog; import android.app.Dialog;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.res.Resources;
import android.os.Bundle; import android.os.Bundle;
import android.text.InputFilter; import android.text.InputFilter;
import android.text.Spanned; import android.text.Spanned;
import android.util.TypedValue;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import androidx.annotation.ColorInt;
import androidx.annotation.Keep;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
@ -29,6 +33,7 @@ import org.citra.citra_emu.utils.Log;
import java.util.Objects; import java.util.Objects;
@Keep
public final class SoftwareKeyboard { public final class SoftwareKeyboard {
/// Corresponds to Frontend::ButtonConfig /// Corresponds to Frontend::ButtonConfig
private interface ButtonConfig { private interface ButtonConfig {
@ -57,6 +62,7 @@ public final class SoftwareKeyboard {
EmptyInputNotAllowed, EmptyInputNotAllowed,
} }
@Keep
public static class KeyboardConfig implements java.io.Serializable { public static class KeyboardConfig implements java.io.Serializable {
public int button_config; public int button_config;
public int max_text_length; public int max_text_length;
@ -109,20 +115,27 @@ public final class SoftwareKeyboard {
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.leftMargin = params.rightMargin = params.leftMargin = params.rightMargin =
CitraApplication.getAppContext().getResources().getDimensionPixelSize( CitraApplication.Companion.getAppContext().getResources().getDimensionPixelSize(
R.dimen.dialog_margin); R.dimen.dialog_margin);
KeyboardConfig config = Objects.requireNonNull( KeyboardConfig config = Objects.requireNonNull(
(KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config")); (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config"));
// Set up the input // Set up the input
EditText editText = new EditText(CitraApplication.getAppContext()); EditText editText = new EditText(CitraApplication.Companion.getAppContext());
editText.setHint(config.hint_text); editText.setHint(config.hint_text);
editText.setSingleLine(!config.multiline_mode); editText.setSingleLine(!config.multiline_mode);
editText.setLayoutParams(params); editText.setLayoutParams(params);
editText.setFilters(new InputFilter[]{ editText.setFilters(new InputFilter[]{
new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); 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); FrameLayout container = new FrameLayout(emulationActivity);
container.addView(editText); container.addView(editText);
@ -256,7 +269,7 @@ public final class SoftwareKeyboard {
public static void ShowError(String error) { public static void ShowError(String error) {
NativeLibrary.displayAlertMsg( NativeLibrary.displayAlertMsg(
CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard), CitraApplication.Companion.getAppContext().getResources().getString(R.string.software_keyboard),
error, false); error, false);
} }

View File

@ -13,6 +13,7 @@ import org.citra.citra_emu.R;
import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.utils.PicassoUtils; import org.citra.citra_emu.utils.PicassoUtils;
import androidx.annotation.Keep;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
// Used in native code. // Used in native code.
@ -23,6 +24,7 @@ public final class StillImageCameraHelper {
String filePickerPath; String filePickerPath;
// Opens file picker for camera. // Opens file picker for camera.
@Keep
public static @Nullable public static @Nullable
String OpenFilePicker() { String OpenFilePicker() {
final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); 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. // Blocking call. Load image from file and crop/resize it to fit in width x height.
@Keep
@Nullable @Nullable
public static Bitmap LoadImageFromFile(String uri, int width, int height) { public static Bitmap LoadImageFromFile(String uri, int width, int height) {
return PicassoUtils.LoadBitmapFromFile(uri, width, height); return PicassoUtils.LoadBitmapFromFile(uri, width, height);

View File

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

View File

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

View File

@ -51,8 +51,7 @@ public class CheatsActivity extends AppCompatActivity
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
ThemeUtil.applyTheme(this); ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
WindowCompat.setDecorFitsSystemWindows(getWindow(), false); WindowCompat.setDecorFitsSystemWindows(getWindow(), false);

View File

@ -14,7 +14,12 @@ import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
public class Settings { 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_CORE = "Core";
public static final String SECTION_SYSTEM = "System"; public static final String SECTION_SYSTEM = "System";
public static final String SECTION_CAMERA = "Camera"; public static final String SECTION_CAMERA = "Camera";
@ -30,7 +35,7 @@ public class Settings {
private static final Map<String, List<String>> configFileSectionsMap = new HashMap<>(); private static final Map<String, List<String>> configFileSectionsMap = new HashMap<>();
static { 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) { public void saveSettings(SettingsActivityView view) {
if (TextUtils.isEmpty(gameId)) { 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<String, List<String>> entry : configFileSectionsMap.entrySet()) { for (Map.Entry<String, List<String>> entry : configFileSectionsMap.entrySet()) {
String fileName = entry.getKey(); String fileName = entry.getKey();
@ -121,12 +126,6 @@ public class Settings {
SettingsFile.saveFile(fileName, iniSections, view); SettingsFile.saveFile(fileName, iniSections, view);
} }
} else { }
// custom game settings
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false);
SettingsFile.saveCustomGameSettings(gameId, sections);
}
} }
} }

View File

@ -59,7 +59,7 @@ public final class CheckBoxSetting extends SettingsItem {
public IntSetting setChecked(boolean checked) { public IntSetting setChecked(boolean checked) {
// Show a performance warning if the setting has been disabled // Show a performance warning if the setting has been disabled
if (mShowPerformanceWarning && !checked) { 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) { if (getSetting() == null) {

View File

@ -201,7 +201,7 @@ public final class InputBindingSetting extends SettingsItem {
*/ */
public void removeOldMapping() { public void removeOldMapping() {
// Get preferences editor // Get preferences editor
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
// Try remove all possible keys we wrote for this setting // 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) { private void WriteButtonMapping(String key) {
// Get preferences editor // Get preferences editor
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
// Remove mapping for another setting using this input // 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) { private void WriteAxisMapping(int axis, int value) {
// Get preferences editor // Get preferences editor
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
// Cleanup old mapping // Cleanup old mapping
@ -302,7 +302,7 @@ public final class InputBindingSetting extends SettingsItem {
*/ */
public void onKeyInput(KeyEvent keyEvent) { public void onKeyInput(KeyEvent keyEvent) {
if (!IsButtonMappingSupported()) { 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; return;
} }
@ -324,11 +324,11 @@ public final class InputBindingSetting extends SettingsItem {
public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange, public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange,
char axisDir) { char axisDir) {
if (!IsAxisMappingSupported()) { 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; return;
} }
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
int button; 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. * Sets the string to use in the configuration UI for the gamepad input.
*/ */
private StringSetting setUiString(String ui) { private StringSetting setUiString(String ui) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
SharedPreferences.Editor editor = preferences.edit(); SharedPreferences.Editor editor = preferences.edit();
if (getSetting() == null) { if (getSetting() == null) {

View File

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

View File

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

View File

@ -20,7 +20,6 @@ public abstract class SettingsItem {
public static final int TYPE_INPUT_BINDING = 5; public static final int TYPE_INPUT_BINDING = 5;
public static final int TYPE_STRING_SINGLE_CHOICE = 6; public static final int TYPE_STRING_SINGLE_CHOICE = 6;
public static final int TYPE_DATETIME_SETTING = 7; public static final int TYPE_DATETIME_SETTING = 7;
public static final int TYPE_PREMIUM = 8;
private String mKey; private String mKey;
private String mSection; private String mSection;
@ -29,7 +28,6 @@ public abstract class SettingsItem {
private int mNameId; private int mNameId;
private int mDescriptionId; private int mDescriptionId;
private boolean mIsPremium;
/** /**
* Base constructor. Takes a key / section name in case the third parameter, the Setting, * Base constructor. Takes a key / section name in case the third parameter, the Setting,
@ -48,7 +46,6 @@ public abstract class SettingsItem {
mSetting = setting; mSetting = setting;
mNameId = nameId; mNameId = nameId;
mDescriptionId = descriptionId; mDescriptionId = descriptionId;
mIsPremium = (section == Settings.SECTION_PREMIUM);
} }
/** /**
@ -93,10 +90,6 @@ public abstract class SettingsItem {
return mDescriptionId; return mDescriptionId;
} }
public boolean isPremium() {
return mIsPremium;
}
/** /**
* Used by {@link SettingsAdapter}'s onCreateViewHolder() * Used by {@link SettingsAdapter}'s onCreateViewHolder()
* method to determine which type of ViewHolder should be created. * method to determine which type of ViewHolder should be created.

View File

@ -26,7 +26,6 @@ import com.google.android.material.appbar.MaterialToolbar;
import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.NativeLibrary;
import org.citra.citra_emu.R; import org.citra.citra_emu.R;
import org.citra.citra_emu.utils.DirectoryInitialization; 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.EmulationMenuSettings;
import org.citra.citra_emu.utils.InsetsHelper; import org.citra.citra_emu.utils.InsetsHelper;
import org.citra.citra_emu.utils.ThemeUtil; import org.citra.citra_emu.utils.ThemeUtil;
@ -48,8 +47,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
ThemeUtil.applyTheme(this); ThemeUtil.INSTANCE.setTheme(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings); setContentView(R.layout.activity_settings);
@ -109,7 +107,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
mPresenter.onStop(isFinishing()); mPresenter.onStop(isFinishing());
// Update framebuffer layout when closing the settings // Update framebuffer layout when closing the settings
NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), NativeLibrary.INSTANCE.notifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
getWindowManager().getDefaultDisplay().getRotation()); getWindowManager().getDefaultDisplay().getRotation());
} }
@ -147,19 +145,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
return duration != 0 && transition != 0; 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 @Override
public void showLoading() { public void showLoading() {
if (dialog == null) { if (dialog == null) {

View File

@ -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.features.settings.utils.SettingsFile;
import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.DirectoryInitialization;
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; 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.Log;
import org.citra.citra_emu.utils.ThemeUtil; import org.citra.citra_emu.utils.ThemeUtil;
@ -24,8 +23,6 @@ public final class SettingsActivityPresenter {
private boolean mShouldSave; private boolean mShouldSave;
private DirectoryStateReceiver directoryStateReceiver;
private String menuTag; private String menuTag;
private String gameId; private String gameId;
@ -64,30 +61,7 @@ public final class SettingsActivityPresenter {
if (configFile == null || !configFile.exists()) { if (configFile == null || !configFile.exists()) {
Log.error("Citra config file could not be found!"); Log.error("Citra config file could not be found!");
} }
if (DirectoryInitialization.areCitraDirectoriesReady()) {
loadSettingsUI(); 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);
}
} }
public void setSettings(Settings settings) { public void setSettings(Settings settings) {
@ -99,17 +73,12 @@ public final class SettingsActivityPresenter {
} }
public void onStop(boolean finishing) { public void onStop(boolean finishing) {
if (directoryStateReceiver != null) {
mView.stopListeningToDirectoryInitializationService(directoryStateReceiver);
directoryStateReceiver = null;
}
if (mSettings != null && finishing && mShouldSave) { if (mSettings != null && finishing && mShouldSave) {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI..."); Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...");
mSettings.saveSettings(mView); mSettings.saveSettings(mView);
} }
NativeLibrary.ReloadSettings(); NativeLibrary.INSTANCE.reloadSettings();
} }
public void onSettingChanged() { public void onSettingChanged() {

View File

@ -3,7 +3,6 @@ package org.citra.citra_emu.features.settings.ui;
import android.content.IntentFilter; import android.content.IntentFilter;
import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.model.Settings;
import org.citra.citra_emu.utils.DirectoryStateReceiver;
/** /**
* Abstraction for the Activity that manages SettingsFragments. * 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 * Show a hint to the user that the app needs the external storage to be mounted
*/ */
void showExternalStorageNotMountedHint(); 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);
} }

View File

@ -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.CheckBoxSetting;
import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; 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.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.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SliderSetting; 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.DateTimeViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; 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.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.SettingViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; 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.SliderViewHolder;
import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; 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 org.citra.citra_emu.utils.Log;
import java.util.ArrayList; import java.util.ArrayList;
@ -97,10 +94,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
view = inflater.inflate(R.layout.list_item_setting, parent, false); view = inflater.inflate(R.layout.list_item_setting, parent, false);
return new DateTimeViewHolder(view, this); return new DateTimeViewHolder(view, this);
case SettingsItem.TYPE_PREMIUM:
view = inflater.inflate(R.layout.premium_item_setting, parent, false);
return new PremiumViewHolder(view, this, mView);
default: default:
Log.error("[SettingsAdapter] Invalid view type: " + viewType); Log.error("[SettingsAdapter] Invalid view type: " + viewType);
return null; return null;
@ -146,17 +139,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
mView.onSettingChanged(); mView.onSettingChanged();
} }
public void onSingleChoiceClick(PremiumSingleChoiceSetting item) {
mClickedItem = item;
int value = getSelectionForSingleChoiceValue(item);
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mView.getActivity())
.setTitle(item.getNameId())
.setSingleChoiceItems(item.getChoicesId(), value, this);
mDialog = builder.show();
}
public void onSingleChoiceClick(SingleChoiceSetting item) { public void onSingleChoiceClick(SingleChoiceSetting item) {
mClickedItem = item; mClickedItem = item;
@ -170,28 +152,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
public void onSingleChoiceClick(SingleChoiceSetting item, int position) { public void onSingleChoiceClick(SingleChoiceSetting item, int position) {
mClickedPosition = position; mClickedPosition = position;
if (!item.isPremium() || MainActivity.isPremiumActive()) {
// Setting is either not Premium, or the user has Premium
onSingleChoiceClick(item); onSingleChoiceClick(item);
return;
}
// User needs Premium, invoke the billing flow
MainActivity.invokePremiumBilling(() -> 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));
} }
public void onStringSingleChoiceClick(StringSingleChoiceSetting item) { public void onStringSingleChoiceClick(StringSingleChoiceSetting item) {
@ -205,15 +166,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) { public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) {
mClickedPosition = position; mClickedPosition = position;
if (!item.isPremium() || MainActivity.isPremiumActive()) {
// Setting is either not Premium, or the user has Premium
onStringSingleChoiceClick(item); onStringSingleChoiceClick(item);
return;
}
// User needs Premium, invoke the billing flow
MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item));
} }
DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog(); DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog();
@ -351,10 +304,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
mView.putSetting(setting); mView.putSetting(setting);
} }
closeDialog();
} else if (mClickedItem instanceof PremiumSingleChoiceSetting) {
PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem;
scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which));
closeDialog(); closeDialog();
} else if (mClickedItem instanceof StringSingleChoiceSetting) { } else if (mClickedItem instanceof StringSingleChoiceSetting) {
StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem; StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem;
@ -417,17 +366,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
} }
} }
private int getValueForSingleChoiceSelection(PremiumSingleChoiceSetting item, int which) {
int valuesId = item.getValuesId();
if (valuesId > 0) {
int[] valuesArray = mContext.getResources().getIntArray(valuesId);
return valuesArray[which];
} else {
return which;
}
}
private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) { private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) {
int value = item.getSelectedValue(); int value = item.getSelectedValue();
int valuesId = item.getValuesId(); int valuesId = item.getValuesId();
@ -447,25 +385,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
return -1; return -1;
} }
private int getSelectionForSingleChoiceValue(PremiumSingleChoiceSetting item) {
int value = item.getSelectedValue();
int valuesId = item.getValuesId();
if (valuesId > 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 @Override
public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) { public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) {
mSliderProgress = (int) value; mSliderProgress = (int) value;

View File

@ -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.DateTimeSetting;
import org.citra.citra_emu.features.settings.model.view.HeaderSetting; 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.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.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.SliderSetting; import org.citra.citra_emu.features.settings.model.view.SliderSetting;
@ -107,9 +105,6 @@ public final class SettingsFragmentPresenter {
case SettingsFile.FILE_NAME_CONFIG: case SettingsFile.FILE_NAME_CONFIG:
addConfigSettings(sl); addConfigSettings(sl);
break; break;
case Settings.SECTION_PREMIUM:
addPremiumSettings(sl);
break;
case Settings.SECTION_CORE: case Settings.SECTION_CORE:
addGeneralSettings(sl); addGeneralSettings(sl);
break; break;
@ -143,7 +138,6 @@ public final class SettingsFragmentPresenter {
private void addConfigSettings(ArrayList<SettingsItem> sl) { private void addConfigSettings(ArrayList<SettingsItem> sl) {
mView.getActivity().setTitle(R.string.preferences_settings); 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_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_system, 0, Settings.SECTION_SYSTEM));
sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA)); 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)); sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG));
} }
private void addPremiumSettings(ArrayList<SettingsItem> 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<SettingsItem> sl) { private void addGeneralSettings(ArrayList<SettingsItem> sl) {
mView.getActivity().setTitle(R.string.preferences_general); mView.getActivity().setTitle(R.string.preferences_general);
@ -367,6 +342,7 @@ public final class SettingsFragmentPresenter {
Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D); Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D);
Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D); Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D);
Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE); 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); SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT);
Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE); Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE);
Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT); 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_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_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 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 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)); sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode));

View File

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

View File

@ -5,7 +5,6 @@ import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import org.citra.citra_emu.R; 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.SettingsItem;
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
@ -46,17 +45,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
mTextSettingDescription.setText(choices[i]); 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 { } else {
mTextSettingDescription.setVisibility(View.GONE); mTextSettingDescription.setVisibility(View.GONE);
} }
@ -67,8 +55,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
int position = getAdapterPosition(); int position = getAdapterPosition();
if (mItem instanceof SingleChoiceSetting) { if (mItem instanceof SingleChoiceSetting) {
getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position); getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position);
} else if (mItem instanceof PremiumSingleChoiceSetting) {
getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position);
} else if (mItem instanceof StringSingleChoiceSetting) { } else if (mItem instanceof StringSingleChoiceSetting) {
getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position); getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position);
} }

View File

@ -42,7 +42,6 @@ public final class SettingsFile {
public static final String KEY_DESIGN = "design"; 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_GRAPHICS_API = "graphics_api";
public static final String KEY_SPIRV_SHADER_GEN = "spirv_shader_gen"; public static final String KEY_SPIRV_SHADER_GEN = "spirv_shader_gen";
@ -160,7 +159,7 @@ public final class SettingsFile {
BufferedReader reader = null; BufferedReader reader = null;
try { try {
Context context = CitraApplication.getAppContext(); Context context = CitraApplication.Companion.getAppContext();
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
reader = new BufferedReader(new InputStreamReader(inputStream)); reader = new BufferedReader(new InputStreamReader(inputStream));
@ -226,7 +225,7 @@ public final class SettingsFile {
DocumentFile ini = getSettingsFile(fileName); DocumentFile ini = getSettingsFile(fileName);
try { try {
Context context = CitraApplication.getAppContext(); Context context = CitraApplication.Companion.getAppContext();
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
Wini writer = new Wini(inputStream); Wini writer = new Wini(inputStream);
@ -242,24 +241,7 @@ public final class SettingsFile {
outputStream.close(); outputStream.close();
} catch (IOException e) { } catch (IOException e) {
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage());
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
}
}
public static void saveCustomGameSettings(final String gameId, final HashMap<String, SettingSection> sections) {
Set<String> sortedSections = new TreeSet<>(sections.keySet());
for (String sectionKey : sortedSections) {
SettingSection section = sections.get(sectionKey);
HashMap<String, Setting> settings = section.getSettings();
Set<String> sortedKeySet = new TreeSet<>(settings.keySet());
for (String settingKey : sortedKeySet) {
Setting setting = settings.get(settingKey);
NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString());
}
} }
} }
@ -280,13 +262,13 @@ public final class SettingsFile {
} }
public static DocumentFile getSettingsFile(String fileName) { 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"); DocumentFile configDirectory = root.findFile("config");
return configDirectory.findFile(fileName + ".ini"); return configDirectory.findFile(fileName + ".ini");
} }
private static DocumentFile getCustomGameSettingsFile(String gameId) { 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"); DocumentFile configDirectory = root.findFile("GameSettings");
return configDirectory.findFile(gameId + ".ini"); return configDirectory.findFile(gameId + ".ini");
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
}

View File

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

View File

@ -27,7 +27,6 @@ import org.citra.citra_emu.activities.EmulationActivity;
import org.citra.citra_emu.overlay.InputOverlay; import org.citra.citra_emu.overlay.InputOverlay;
import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.DirectoryInitialization;
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; 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.EmulationMenuSettings;
import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.Log;
@ -42,8 +41,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
private EmulationState mEmulationState; private EmulationState mEmulationState;
private DirectoryStateReceiver directoryStateReceiver;
private EmulationActivity activity; private EmulationActivity activity;
private TextView mPerfStats; private TextView mPerfStats;
@ -65,7 +62,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
if (context instanceof EmulationActivity) { if (context instanceof EmulationActivity) {
activity = (EmulationActivity) context; activity = (EmulationActivity) context;
NativeLibrary.setEmulationActivity((EmulationActivity) context); NativeLibrary.INSTANCE.setEmulationActivity((EmulationActivity) context);
} else { } else {
throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); throw new IllegalStateException("EmulationFragment must have EmulationActivity parent");
} }
@ -116,20 +113,11 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
Choreographer.getInstance().postFrameCallback(this); Choreographer.getInstance().postFrameCallback(this);
if (DirectoryInitialization.areCitraDirectoriesReady()) {
mEmulationState.run(activity.isActivityRecreated()); mEmulationState.run(activity.isActivityRecreated());
} else {
setupCitraDirectoriesThenStartEmulation();
}
} }
@Override @Override
public void onPause() { public void onPause() {
if (directoryStateReceiver != null) {
LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver);
directoryStateReceiver = null;
}
if (mEmulationState.isRunning()) { if (mEmulationState.isRunning()) {
mEmulationState.pause(); mEmulationState.pause();
} }
@ -140,39 +128,10 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
@Override @Override
public void onDetach() { public void onDetach() {
NativeLibrary.clearEmulationActivity(); NativeLibrary.INSTANCE.clearEmulationActivity();
super.onDetach(); 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() { public void refreshInputOverlay() {
mInputOverlay.refreshControls(); mInputOverlay.refreshControls();
} }
@ -195,7 +154,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
perfStatsUpdater = () -> perfStatsUpdater = () ->
{ {
final double[] perfStats = NativeLibrary.GetPerfStats(); final double[] perfStats = NativeLibrary.INSTANCE.getPerfStats();
if (perfStats[FPS] > 0) { if (perfStats[FPS] > 0) {
mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5), mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5),
(int) (perfStats[SPEED] * 100.0 + 0.5))); (int) (perfStats[SPEED] * 100.0 + 0.5)));
@ -235,7 +194,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
@Override @Override
public void doFrame(long frameTimeNanos) { public void doFrame(long frameTimeNanos) {
Choreographer.getInstance().postFrameCallback(this); Choreographer.getInstance().postFrameCallback(this);
NativeLibrary.DoFrame(); NativeLibrary.INSTANCE.doFrame();
} }
public void stopEmulation() { public void stopEmulation() {
@ -286,7 +245,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
if (state != State.STOPPED) { if (state != State.STOPPED) {
Log.debug("[EmulationFragment] Stopping emulation."); Log.debug("[EmulationFragment] Stopping emulation.");
state = State.STOPPED; state = State.STOPPED;
NativeLibrary.StopEmulation(); NativeLibrary.INSTANCE.stopEmulation();
} else { } else {
Log.warning("[EmulationFragment] Stop called while already stopped."); 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."); Log.debug("[EmulationFragment] Pausing emulation.");
// Release the surface before pausing, since emulation has to be running for that. // Release the surface before pausing, since emulation has to be running for that.
NativeLibrary.SurfaceDestroyed(); NativeLibrary.INSTANCE.surfaceDestroyed();
NativeLibrary.PauseEmulation(); NativeLibrary.INSTANCE.pauseEmulation();
} else { } else {
Log.warning("[EmulationFragment] Pause called while already paused."); 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) { public synchronized void run(boolean isActivityRecreated) {
if (isActivityRecreated) { if (isActivityRecreated) {
if (NativeLibrary.IsRunning()) { if (NativeLibrary.INSTANCE.isRunning()) {
state = State.PAUSED; state = State.PAUSED;
} }
} else { } else {
@ -340,7 +299,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
Log.debug("[EmulationFragment] Surface destroyed."); Log.debug("[EmulationFragment] Surface destroyed.");
if (state == State.RUNNING) { if (state == State.RUNNING) {
NativeLibrary.SurfaceDestroyed(); NativeLibrary.INSTANCE.surfaceDestroyed();
state = State.PAUSED; state = State.PAUSED;
} else if (state == State.PAUSED) { } else if (state == State.PAUSED) {
Log.warning("[EmulationFragment] Surface cleared while emulation 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() { private void runWithValidSurface() {
mRunWhenSurfaceIsValid = false; mRunWhenSurfaceIsValid = false;
if (state == State.STOPPED) { if (state == State.STOPPED) {
NativeLibrary.SurfaceChanged(mSurface); NativeLibrary.INSTANCE.surfaceChanged(mSurface);
Thread mEmulationThread = new Thread(() -> Thread mEmulationThread = new Thread(() ->
{ {
Log.debug("[EmulationFragment] Starting emulation thread."); Log.debug("[EmulationFragment] Starting emulation thread.");
NativeLibrary.Run(mGamePath); NativeLibrary.INSTANCE.run(mGamePath);
}, "NativeEmulation"); }, "NativeEmulation");
mEmulationThread.start(); mEmulationThread.start();
} else if (state == State.PAUSED) { } else if (state == State.PAUSED) {
Log.debug("[EmulationFragment] Resuming emulation."); Log.debug("[EmulationFragment] Resuming emulation.");
NativeLibrary.SurfaceChanged(mSurface); NativeLibrary.INSTANCE.surfaceChanged(mSurface);
NativeLibrary.UnPauseEmulation(); NativeLibrary.INSTANCE.unPauseEmulation();
} else { } else {
Log.debug("[EmulationFragment] Bug, run called while already running."); Log.debug("[EmulationFragment] Bug, run called while already running.");
} }

View File

@ -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<Game>) {
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
}
}

View File

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

View File

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

View File

@ -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>(view.parent as View).state =
BottomSheetBehavior.STATE_HALF_EXPANDED
val license = requireArguments().parcelable<License>(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>(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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<SetupPage>
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<Uri, Uri>(
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
}
}

View File

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

View File

@ -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<String, String> = 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
}
}

View File

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

View File

@ -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<String> get() = extensions + badExtensions
val extensions: Set<String> = HashSet(
listOf("3ds", "3dsx", "elf", "axf", "cci", "cxi", "app")
)
val badExtensions: Set<String> = HashSet(
listOf("rar", "zip", "7z", "torrent", "tar", "gz")
)
}
}

View File

@ -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<String> allowedExtensions = new HashSet<String>(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<String> allowedExtensions, int depth) {
if (depth <= 0) {
return;
}
for (CheapDocument file : files) {
if (file.isDirectory()) {
Set<String> 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<Cursor> 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);
}
}

View File

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

View File

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

View File

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

View File

@ -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<String> = MutableStateFlow("")
)

View File

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

View File

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

View File

@ -347,7 +347,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (!button.updateStatus(event)) { if (!button.updateStatus(event)) {
continue; continue;
} }
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus()); NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus());
shouldUpdateView = true; shouldUpdateView = true;
} }
@ -355,10 +355,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) { if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) {
continue; continue;
} }
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus()); NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus());
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus()); NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus());
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus()); NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus());
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus()); NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus());
shouldUpdateView = true; shouldUpdateView = true;
} }
@ -367,7 +367,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
continue; continue;
} }
int axisID = joystick.getJoystickId(); int axisID = joystick.getJoystickId();
NativeLibrary NativeLibrary.INSTANCE
.onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis()); .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis());
shouldUpdateView = true; 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; boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP;
if (isActionDown && !isTouchInputConsumed(pointerId)) { if (isActionDown && !isTouchInputConsumed(pointerId)) {
NativeLibrary.onTouchEvent(xPosition, yPosition, true); NativeLibrary.INSTANCE.onTouchEvent(xPosition, yPosition, true);
} }
if (isActionMove) { if (isActionMove) {
@ -399,12 +399,12 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
if (isTouchInputConsumed(fingerId)) { if (isTouchInputConsumed(fingerId)) {
continue; continue;
} }
NativeLibrary.onTouchMoved(xPosition, yPosition); NativeLibrary.INSTANCE.onTouchMoved(xPosition, yPosition);
} }
} }
if (isActionUp && !isTouchInputConsumed(pointerId)) { if (isActionUp && !isTouchInputConsumed(pointerId)) {
NativeLibrary.onTouchEvent(0, 0, false); NativeLibrary.INSTANCE.onTouchEvent(0, 0, false);
} }
return true; return true;

View File

@ -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<Uri> mOpenCitraDirectory =
registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> {
if (result == null)
return;
citraDirectoryHelper.showCitraDirectoryDialog(result);
});
private final ActivityResultLauncher<Uri> 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<Boolean> 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<String> 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;
});
}
}

View File

@ -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<Uri, Uri>(
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()
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Purchase> 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<SkuDetails> 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<String> 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);
}
}
}

View File

@ -5,6 +5,7 @@ import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -13,6 +14,7 @@ import androidx.work.ForegroundInfo;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
import org.citra.citra_emu.NativeLibrary.InstallStatus;
import org.citra.citra_emu.R; import org.citra.citra_emu.R;
public class CiaInstallWorker extends Worker { public class CiaInstallWorker extends Worker {
@ -56,15 +58,6 @@ public class CiaInstallWorker extends Worker {
super(context, params); super(context, params);
} }
enum InstallStatus {
Success,
ErrorFailedToOpenFile,
ErrorFileNotFound,
ErrorAborted,
ErrorInvalid,
ErrorEncrypted,
}
private void notifyInstallStatus(String filename, InstallStatus status) { private void notifyInstallStatus(String filename, InstallStatus status) {
switch(status){ switch(status){
case Success: case Success:
@ -126,10 +119,10 @@ public class CiaInstallWorker extends Worker {
int i = 0; int i = 0;
for (String file : selectedFiles) { for (String file : selectedFiles) {
String filename = FileUtil.getFilename(mContext, file); String filename = FileUtil.getFilename(Uri.parse(file));
mInstallProgressBuilder.setContentText(mContext.getString( mInstallProgressBuilder.setContentText(mContext.getString(
R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length)); R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length));
InstallStatus res = InstallCIA(file); InstallStatus res = installCIA(file);
notifyInstallStatus(filename, res); notifyInstallStatus(filename, res);
} }
mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID); mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID);
@ -156,5 +149,5 @@ public class CiaInstallWorker extends Worker {
return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build()); return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build());
} }
private native InstallStatus InstallCIA(String path); private native InstallStatus installCIA(String path);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DirectoryInitializationState> callback;
public DirectoryStateReceiver(Action1<DirectoryInitializationState> callback) {
this.callback = callback;
}
@Override
public void onReceive(Context context, Intent intent) {
DirectoryInitializationState state = (DirectoryInitializationState) intent
.getSerializableExtra(DirectoryInitialization.EXTRA_STATE);
callback.call(state);
}
}

View File

@ -12,6 +12,7 @@ import android.view.View;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.Keep;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
@ -25,6 +26,7 @@ import org.citra.citra_emu.utils.Log;
import java.util.Objects; import java.util.Objects;
@Keep
public class DiskShaderCacheProgress { public class DiskShaderCacheProgress {
// Equivalent to VideoCore::LoadCallbackStage // Equivalent to VideoCore::LoadCallbackStage

View File

@ -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<String, DocumentsNode> 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;
}
}
}

View File

@ -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<String?> {
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<String?, DocumentsNode?> = 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<String?> =
children.mapNotNull { it.value!!.name }.toTypedArray()
}
companion object {
const val DELIMITER = "/"
}
}

View File

@ -6,7 +6,7 @@ import android.preference.PreferenceManager;
import org.citra.citra_emu.CitraApplication; import org.citra.citra_emu.CitraApplication;
public class EmulationMenuSettings { 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 // These must match what is defined in src/common/settings.h
public static final int LayoutOption_Default = 0; public static final int LayoutOption_Default = 0;

View File

@ -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<CheapDocument> 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<String> 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<Pair<CheapDocument, DocumentFile>> files = new ArrayList<>();
final List<Pair<Uri, Uri>> 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<CheapDocument, DocumentFile> 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<String> 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) {
}
}
}
}

View File

@ -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<CheapDocument> {
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<CheapDocument> = 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<CheapDocument>()
}
/**
* 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<String?> {
val uri = Uri.parse(path)
val files: MutableList<String> = ArrayList()
listFiles(uri).forEach { files.add(it.filename) }
return files.toTypedArray<String?>()
}
/**
* 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<Pair<CheapDocument, DocumentFile>> = ArrayList()
val dirs: MutableList<Pair<Uri, Uri>> = 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()
}
}

View File

@ -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<Game> {
val games = mutableListOf<Game>()
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<String>()
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<Game>,
files: Array<CheapDocument>,
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
}
}

View File

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

View File

@ -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<Game> {
override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher =
GameIconFetcher(data, options)
}
}
class GameIconKeyer : Keyer<Game> {
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)
}
}

View File

@ -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<Pair<Uri, GpuDriverMetadata>> {
val driverZips = driverStoragePath.listFiles()
val drivers: MutableList<Pair<Uri, GpuDriverMetadata>> =
driverZips
.mapNotNull {
val metadata = getMetadataFromZip(it.inputStream())
metadata.name?.let { _ -> Pair(it.uri, metadata) }
}
.sortedByDescending { it: Pair<Uri, GpuDriverMetadata> -> 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!")
}
}
}

View File

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

View File

@ -8,6 +8,9 @@ import org.citra.citra_emu.BuildConfig;
* levels in release builds. * levels in release builds.
*/ */
public final class Log { 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 static final String TAG = "Citra Frontend";
private Log() { private Log() {

View File

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

View File

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

View File

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

View File

@ -2,44 +2,14 @@ package org.citra.citra_emu.utils;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import android.widget.ImageView;
import com.squareup.picasso.Picasso; import com.squareup.picasso.Picasso;
import org.citra.citra_emu.CitraApplication;
import org.citra.citra_emu.R;
import java.io.IOException; import java.io.IOException;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
public class PicassoUtils { 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. // Blocking call. Load image from file and crop/resize it to fit in width x height.
@Nullable @Nullable
public static Bitmap LoadBitmapFromFile(String uri, int width, int height) { public static Bitmap LoadBitmapFromFile(String uri, int width, int height) {

View File

@ -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 <reified T : Serializable> 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 <reified T : Serializable> 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 <reified T : Parcelable> 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 <reified T : Parcelable> Intent.parcelable(key: String): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(key, T::class.java)
} else {
getParcelableExtra(key) as? T
}
}

View File

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

View File

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

View File

@ -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)
)
}
}

View File

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

View File

@ -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<Pair<Uri, GpuDriverMetadata>>())
var previouslySelectedDriver = 0
var selectedDriver = -1
private val _selectedDriverMetadata =
MutableStateFlow(
GpuDriverHelper.customDriverData.name
?: CitraApplication.appContext.getString(R.string.system_gpu_driver)
)
val selectedDriverMetadata: StateFlow<String> get() = _selectedDriverMetadata
private val _newDriverInstalled = MutableStateFlow(false)
val newDriverInstalled: StateFlow<Boolean> get() = _newDriverInstalled
val driversToDelete = mutableListOf<Uri>()
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<Uri, GpuDriverMetadata>) {
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<Uri, GpuDriverMetadata>) {
_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)
}
}

View File

@ -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<Game>())
val searchedGames get() = _searchedGames.asStateFlow()
private val _searchedGames = MutableStateFlow(emptyList<Game>())
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<Game>()
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<Game>) {
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<Game>) {
_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)
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More