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:
parent
80ac6c03b5
commit
fa08df21a5
|
@ -2,15 +2,18 @@
|
|||
// Licensed under GPLv2 or any later version
|
||||
// Refer to the license.txt file included.
|
||||
|
||||
import android.databinding.tool.ext.capitalizeUS
|
||||
import de.undercouch.gradle.tasks.download.Download
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("de.undercouch.download") version "5.5.0"
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.8.21"
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
}
|
||||
|
||||
import android.databinding.tool.ext.capitalizeUS
|
||||
import de.undercouch.gradle.tasks.download.Download
|
||||
|
||||
/**
|
||||
* Use the number of seconds/10 since Jan 1 2016 as the versionCode.
|
||||
* This lets us upload a new build at most every 10 seconds for the
|
||||
|
@ -25,7 +28,7 @@ val downloadedJniLibsPath = "${buildDir}/downloadedJniLibs"
|
|||
android {
|
||||
namespace = "org.citra.citra_emu"
|
||||
|
||||
compileSdkVersion = "android-33"
|
||||
compileSdkVersion = "android-34"
|
||||
ndkVersion = "25.2.9519653"
|
||||
|
||||
compileOptions {
|
||||
|
@ -37,6 +40,11 @@ android {
|
|||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
// This is necessary for libadrenotools custom driver loading
|
||||
jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
@ -51,7 +59,7 @@ android {
|
|||
// TODO If this is ever modified, change application_id in strings.xml
|
||||
applicationId = "org.citra.citra_emu"
|
||||
minSdk = 28
|
||||
targetSdk = 33
|
||||
targetSdk = 34
|
||||
versionCode = autoVersion
|
||||
versionName = getGitVersion()
|
||||
|
||||
|
@ -69,6 +77,9 @@ android {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
|
||||
buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
|
||||
}
|
||||
|
||||
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||
|
@ -92,6 +103,12 @@ android {
|
|||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
|
||||
// builds a release build that doesn't need signing
|
||||
|
@ -101,9 +118,15 @@ android {
|
|||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
isDefault = true
|
||||
}
|
||||
|
||||
// Signed by debug key disallowing distribution on Play Store.
|
||||
|
@ -145,8 +168,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.activity:activity-ktx:1.7.2")
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.activity:activity-ktx:1.8.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
|
||||
|
@ -158,15 +182,14 @@ dependencies {
|
|||
// For loading huge screenshots from the disk.
|
||||
implementation("com.squareup.picasso:picasso:2.71828")
|
||||
|
||||
// Allows FRP-style asynchronous operations in Android.
|
||||
implementation("io.reactivex:rxandroid:1.2.1")
|
||||
|
||||
implementation("org.ini4j:ini4j:0.5.4")
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// Please don't upgrade the billing library as the newer version is not GPL-compatible
|
||||
implementation("com.android.billingclient:billing:2.0.3")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.5")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.5")
|
||||
implementation("info.debatty:java-string-similarity:2.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("io.coil-kt:coil:2.2.2")
|
||||
}
|
||||
|
||||
// Download Vulkan Validation Layers from the KhronosGroup GitHub.
|
||||
|
@ -216,6 +239,34 @@ fun getGitVersion(): String {
|
|||
return versionName
|
||||
}
|
||||
|
||||
fun getGitHash(): String =
|
||||
runGitCommand(ProcessBuilder("git", "rev-parse", "--short", "HEAD")) ?: "dummy-hash"
|
||||
|
||||
fun getBranch(): String =
|
||||
runGitCommand(ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")) ?: "dummy-branch"
|
||||
|
||||
fun runGitCommand(command: ProcessBuilder) : String? {
|
||||
try {
|
||||
command.directory(project.rootDir)
|
||||
val process = command.start()
|
||||
val inputStream = process.inputStream
|
||||
val errorStream = process.errorStream
|
||||
process.waitFor()
|
||||
|
||||
return if (process.exitValue() == 0) {
|
||||
inputStream.bufferedReader()
|
||||
.use { it.readText().trim() } // return the value of gitHash
|
||||
} else {
|
||||
val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
|
||||
logger.error("Error running git command: $errorMessage")
|
||||
return null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("$e: Cannot find git")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
android.applicationVariants.configureEach {
|
||||
val variant = this
|
||||
val capitalizedName = variant.name.capitalizeUS()
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
# Copyright 2023 Citra Emulator Project
|
||||
# Licensed under GPLv2 or any later version
|
||||
# Refer to the license.txt file included.
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# To get usable stack traces
|
||||
-dontobfuscate
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
# Prevents crashing when using Wini
|
||||
-keep class org.ini4j.spi.IniParser
|
||||
-keep class org.ini4j.spi.IniBuilder
|
||||
-keep class org.ini4j.spi.IniFormatter
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# Suppress warnings for R8
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn java.beans.Introspector
|
||||
-dontwarn java.beans.VetoableChangeListener
|
||||
-dontwarn java.beans.VetoableChangeSupport
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<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" />
|
||||
|
||||
<application
|
||||
|
@ -44,8 +45,7 @@
|
|||
<activity
|
||||
android:name="org.citra.citra_emu.ui.main.MainActivity"
|
||||
android:theme="@style/Theme.Citra.Splash.Main"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="false">
|
||||
android:exported="true">
|
||||
|
||||
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
|
||||
<intent-filter>
|
||||
|
@ -68,21 +68,15 @@
|
|||
android:theme="@style/Theme.Citra.Main"
|
||||
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
|
||||
android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Citra.Main"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ import android.view.MenuItem;
|
|||
import android.view.MotionEvent;
|
||||
import android.view.SubMenu;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
@ -48,6 +49,7 @@ import org.citra.citra_emu.utils.EmulationMenuSettings;
|
|||
import org.citra.citra_emu.utils.FileBrowserHelper;
|
||||
import org.citra.citra_emu.utils.FileUtil;
|
||||
import org.citra.citra_emu.utils.ForegroundService;
|
||||
import org.citra.citra_emu.utils.Log;
|
||||
import org.citra.citra_emu.utils.ThemeUtil;
|
||||
|
||||
import java.io.File;
|
||||
|
@ -169,8 +171,8 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
ThemeUtil.applyTheme(this);
|
||||
|
||||
Log.gameLaunched = true;
|
||||
ThemeUtil.INSTANCE.setTheme(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
|
@ -210,7 +212,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
startForegroundService(foregroundService);
|
||||
|
||||
// Override Citra core INI with the one set by our in game menu
|
||||
NativeLibrary.SwapScreens(EmulationMenuSettings.getSwapScreens(),
|
||||
NativeLibrary.INSTANCE.swapScreens(EmulationMenuSettings.getSwapScreens(),
|
||||
getWindowManager().getDefaultDisplay().getRotation());
|
||||
}
|
||||
|
||||
|
@ -224,15 +226,12 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
protected void restoreState(Bundle savedInstanceState) {
|
||||
mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME);
|
||||
mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE);
|
||||
|
||||
// If an alert prompt was in progress when state was restored, retry displaying it
|
||||
NativeLibrary.retryDisplayAlertPrompt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestart() {
|
||||
super.onRestart();
|
||||
NativeLibrary.ReloadCameraDevices();
|
||||
NativeLibrary.INSTANCE.reloadCameraDevices();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -257,7 +256,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
|
||||
NativeLibrary.INSTANCE.cameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
|
||||
break;
|
||||
case NativeLibrary.REQUEST_CODE_NATIVE_MIC:
|
||||
if (grantResults[0] != PackageManager.PERMISSION_GRANTED &&
|
||||
|
@ -268,7 +267,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
|
||||
NativeLibrary.INSTANCE.micPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
|
||||
break;
|
||||
default:
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
@ -281,6 +280,10 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void enableFullscreenImmersive() {
|
||||
// TODO: Remove this once we properly account for display insets in the input overlay
|
||||
getWindow().getAttributes().layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
|
||||
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
|
||||
|
@ -323,7 +326,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void DisplaySavestateWarning() {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
if (preferences.getBoolean("savestateWarningShown", false)) {
|
||||
return;
|
||||
}
|
||||
|
@ -350,7 +353,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void updateSavestateMenuOptions(Menu menu) {
|
||||
final NativeLibrary.SavestateInfo[] savestates = NativeLibrary.GetSavestateInfo();
|
||||
final NativeLibrary.SaveStateInfo[] savestates = NativeLibrary.INSTANCE.getSavestateInfo();
|
||||
if (savestates == null) {
|
||||
menu.findItem(R.id.menu_emulation_save_state).setVisible(false);
|
||||
menu.findItem(R.id.menu_emulation_load_state).setVisible(false);
|
||||
|
@ -370,18 +373,18 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
final String text = getString(R.string.emulation_empty_state_slot, slot);
|
||||
saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> {
|
||||
DisplaySavestateWarning();
|
||||
NativeLibrary.SaveState(slot);
|
||||
NativeLibrary.INSTANCE.saveState(slot);
|
||||
return true;
|
||||
});
|
||||
loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> {
|
||||
NativeLibrary.LoadState(slot);
|
||||
NativeLibrary.INSTANCE.loadState(slot);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
for (final NativeLibrary.SavestateInfo info : savestates) {
|
||||
final String text = getString(R.string.emulation_occupied_state_slot, info.slot, info.time);
|
||||
saveStateMenu.getItem(info.slot - 1).setTitle(text);
|
||||
loadStateMenu.getItem(info.slot - 1).setTitle(text).setEnabled(true);
|
||||
for (final NativeLibrary.SaveStateInfo info : savestates) {
|
||||
final String text = getString(R.string.emulation_occupied_state_slot, info.getSlot(), info.getTime());
|
||||
saveStateMenu.getItem(info.getSlot() - 1).setTitle(text);
|
||||
loadStateMenu.getItem(info.getSlot() - 1).setTitle(text).setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -441,7 +444,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
EmulationMenuSettings.setSwapScreens(isEnabled);
|
||||
item.setChecked(isEnabled);
|
||||
|
||||
NativeLibrary.SwapScreens(isEnabled, getWindowManager().getDefaultDisplay()
|
||||
NativeLibrary.INSTANCE.swapScreens(isEnabled, getWindowManager().getDefaultDisplay()
|
||||
.getRotation());
|
||||
break;
|
||||
}
|
||||
|
@ -491,11 +494,11 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
break;
|
||||
|
||||
case MENU_ACTION_OPEN_CHEATS:
|
||||
CheatsActivity.launch(this, NativeLibrary.GetRunningTitleId());
|
||||
CheatsActivity.launch(this, NativeLibrary.INSTANCE.getRunningTitleId());
|
||||
break;
|
||||
|
||||
case MENU_ACTION_CLOSE_GAME:
|
||||
NativeLibrary.PauseEmulation();
|
||||
NativeLibrary.INSTANCE.pauseEmulation();
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.emulation_close_game)
|
||||
.setMessage(R.string.emulation_close_game_message)
|
||||
|
@ -504,8 +507,8 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
mEmulationFragment.stopEmulation();
|
||||
finish();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.UnPauseEmulation())
|
||||
.setOnCancelListener(dialogInterface -> NativeLibrary.UnPauseEmulation())
|
||||
.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.INSTANCE.unPauseEmulation())
|
||||
.setOnCancelListener(dialogInterface -> NativeLibrary.INSTANCE.unPauseEmulation())
|
||||
.show();
|
||||
break;
|
||||
}
|
||||
|
@ -515,7 +518,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
|
||||
private void changeScreenOrientation(int layoutOption, MenuItem item) {
|
||||
item.setChecked(true);
|
||||
NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
|
||||
NativeLibrary.INSTANCE.notifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
|
||||
.getRotation());
|
||||
EmulationMenuSettings.setLandscapeScreenLayout(layoutOption);
|
||||
}
|
||||
|
@ -558,7 +561,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
return false;
|
||||
}
|
||||
|
||||
return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action);
|
||||
return NativeLibrary.INSTANCE.onGamePadEvent(input.getDescriptor(), button, action);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -570,7 +573,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void onAmiiboSelected(String selectedFile) {
|
||||
boolean success = NativeLibrary.LoadAmiibo(selectedFile);
|
||||
boolean success = NativeLibrary.INSTANCE.loadAmiibo(selectedFile);
|
||||
|
||||
if (!success) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
|
@ -582,7 +585,7 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void RemoveAmiibo() {
|
||||
NativeLibrary.RemoveAmiibo();
|
||||
NativeLibrary.INSTANCE.removeAmiibo();
|
||||
}
|
||||
|
||||
private void toggleControls() {
|
||||
|
@ -725,47 +728,47 @@ public final class EmulationActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
// Circle-Pad and C-Stick status
|
||||
NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]);
|
||||
NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]);
|
||||
NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]);
|
||||
NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]);
|
||||
|
||||
// Triggers L/R and ZL/ZR
|
||||
if (isTriggerPressedLMapped) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (isTriggerPressedRMapped) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (isTriggerPressedZLMapped) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (isTriggerPressedZRMapped) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
|
||||
// Work-around to allow D-pad axis to be bound to emulated buttons
|
||||
if (axisValuesDPad[0] == 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (axisValuesDPad[0] < 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (axisValuesDPad[0] > 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED);
|
||||
}
|
||||
if (axisValuesDPad[1] == 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (axisValuesDPad[1] < 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
|
||||
}
|
||||
if (axisValuesDPad[1] > 0.f) {
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,13 +18,16 @@ import java.util.Arrays;
|
|||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
@Keep
|
||||
public final class MiiSelector {
|
||||
@Keep
|
||||
public static class MiiSelectorConfig implements java.io.Serializable {
|
||||
public boolean enable_cancel_button;
|
||||
public String title;
|
||||
|
|
|
@ -7,13 +7,17 @@ package org.citra.citra_emu.applets;
|
|||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputFilter;
|
||||
import android.text.Spanned;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
@ -29,6 +33,7 @@ import org.citra.citra_emu.utils.Log;
|
|||
|
||||
import java.util.Objects;
|
||||
|
||||
@Keep
|
||||
public final class SoftwareKeyboard {
|
||||
/// Corresponds to Frontend::ButtonConfig
|
||||
private interface ButtonConfig {
|
||||
|
@ -57,6 +62,7 @@ public final class SoftwareKeyboard {
|
|||
EmptyInputNotAllowed,
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static class KeyboardConfig implements java.io.Serializable {
|
||||
public int button_config;
|
||||
public int max_text_length;
|
||||
|
@ -109,20 +115,27 @@ public final class SoftwareKeyboard {
|
|||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
params.leftMargin = params.rightMargin =
|
||||
CitraApplication.getAppContext().getResources().getDimensionPixelSize(
|
||||
CitraApplication.Companion.getAppContext().getResources().getDimensionPixelSize(
|
||||
R.dimen.dialog_margin);
|
||||
|
||||
KeyboardConfig config = Objects.requireNonNull(
|
||||
(KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config"));
|
||||
|
||||
// Set up the input
|
||||
EditText editText = new EditText(CitraApplication.getAppContext());
|
||||
EditText editText = new EditText(CitraApplication.Companion.getAppContext());
|
||||
editText.setHint(config.hint_text);
|
||||
editText.setSingleLine(!config.multiline_mode);
|
||||
editText.setLayoutParams(params);
|
||||
editText.setFilters(new InputFilter[]{
|
||||
new Filter(), new InputFilter.LengthFilter(config.max_text_length)});
|
||||
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = requireContext().getTheme();
|
||||
theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true);
|
||||
@ColorInt int color = typedValue.data;
|
||||
editText.setHintTextColor(color);
|
||||
editText.setTextColor(color);
|
||||
|
||||
FrameLayout container = new FrameLayout(emulationActivity);
|
||||
container.addView(editText);
|
||||
|
||||
|
@ -256,7 +269,7 @@ public final class SoftwareKeyboard {
|
|||
|
||||
public static void ShowError(String error) {
|
||||
NativeLibrary.displayAlertMsg(
|
||||
CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard),
|
||||
CitraApplication.Companion.getAppContext().getResources().getString(R.string.software_keyboard),
|
||||
error, false);
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import org.citra.citra_emu.R;
|
|||
import org.citra.citra_emu.activities.EmulationActivity;
|
||||
import org.citra.citra_emu.utils.PicassoUtils;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
// Used in native code.
|
||||
|
@ -23,6 +24,7 @@ public final class StillImageCameraHelper {
|
|||
String filePickerPath;
|
||||
|
||||
// Opens file picker for camera.
|
||||
@Keep
|
||||
public static @Nullable
|
||||
String OpenFilePicker() {
|
||||
final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
|
||||
|
@ -58,6 +60,7 @@ public final class StillImageCameraHelper {
|
|||
}
|
||||
|
||||
// Blocking call. Load image from file and crop/resize it to fit in width x height.
|
||||
@Keep
|
||||
@Nullable
|
||||
public static Bitmap LoadImageFromFile(String uri, int width, int height) {
|
||||
return PicassoUtils.LoadBitmapFromFile(uri, width, height);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -51,8 +51,7 @@ public class CheatsActivity extends AppCompatActivity
|
|||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
ThemeUtil.applyTheme(this);
|
||||
|
||||
ThemeUtil.INSTANCE.setTheme(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
|
|
|
@ -14,7 +14,12 @@ import java.util.Map;
|
|||
import java.util.TreeMap;
|
||||
|
||||
public class Settings {
|
||||
public static final String SECTION_PREMIUM = "Premium";
|
||||
public static final String PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch";
|
||||
public static final String PREF_MATERIAL_YOU = "MaterialYouTheme";
|
||||
public static final String PREF_THEME_MODE = "ThemeMode";
|
||||
public static final String PREF_BLACK_BACKGROUNDS = "BlackBackgrounds";
|
||||
public static final String PREF_SHOW_HOME_APPS = "ShowHomeApps";
|
||||
|
||||
public static final String SECTION_CORE = "Core";
|
||||
public static final String SECTION_SYSTEM = "System";
|
||||
public static final String SECTION_CAMERA = "Camera";
|
||||
|
@ -30,7 +35,7 @@ public class Settings {
|
|||
private static final Map<String, List<String>> configFileSectionsMap = new HashMap<>();
|
||||
|
||||
static {
|
||||
configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG));
|
||||
configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -109,7 +114,7 @@ public class Settings {
|
|||
|
||||
public void saveSettings(SettingsActivityView view) {
|
||||
if (TextUtils.isEmpty(gameId)) {
|
||||
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false);
|
||||
view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.ini_saved), false);
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : configFileSectionsMap.entrySet()) {
|
||||
String fileName = entry.getKey();
|
||||
|
@ -121,12 +126,6 @@ public class Settings {
|
|||
|
||||
SettingsFile.saveFile(fileName, iniSections, view);
|
||||
}
|
||||
} else {
|
||||
// custom game settings
|
||||
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false);
|
||||
|
||||
SettingsFile.saveCustomGameSettings(gameId, sections);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -59,7 +59,7 @@ public final class CheckBoxSetting extends SettingsItem {
|
|||
public IntSetting setChecked(boolean checked) {
|
||||
// Show a performance warning if the setting has been disabled
|
||||
if (mShowPerformanceWarning && !checked) {
|
||||
mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true);
|
||||
mView.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.performance_warning), true);
|
||||
}
|
||||
|
||||
if (getSetting() == null) {
|
||||
|
|
|
@ -201,7 +201,7 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
*/
|
||||
public void removeOldMapping() {
|
||||
// Get preferences editor
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
|
||||
// Try remove all possible keys we wrote for this setting
|
||||
|
@ -250,7 +250,7 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
*/
|
||||
private void WriteButtonMapping(String key) {
|
||||
// Get preferences editor
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
|
||||
// Remove mapping for another setting using this input
|
||||
|
@ -278,7 +278,7 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
*/
|
||||
private void WriteAxisMapping(int axis, int value) {
|
||||
// Get preferences editor
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
|
||||
// Cleanup old mapping
|
||||
|
@ -302,7 +302,7 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
*/
|
||||
public void onKeyInput(KeyEvent keyEvent) {
|
||||
if (!IsButtonMappingSupported()) {
|
||||
Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -324,11 +324,11 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange,
|
||||
char axisDir) {
|
||||
if (!IsAxisMappingSupported()) {
|
||||
Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
|
||||
int button;
|
||||
|
@ -354,7 +354,7 @@ public final class InputBindingSetting extends SettingsItem {
|
|||
* Sets the string to use in the configuration UI for the gamepad input.
|
||||
*/
|
||||
private StringSetting setUiString(String ui) {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
|
||||
if (getSetting() == null) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ public abstract class SettingsItem {
|
|||
public static final int TYPE_INPUT_BINDING = 5;
|
||||
public static final int TYPE_STRING_SINGLE_CHOICE = 6;
|
||||
public static final int TYPE_DATETIME_SETTING = 7;
|
||||
public static final int TYPE_PREMIUM = 8;
|
||||
|
||||
private String mKey;
|
||||
private String mSection;
|
||||
|
@ -29,7 +28,6 @@ public abstract class SettingsItem {
|
|||
|
||||
private int mNameId;
|
||||
private int mDescriptionId;
|
||||
private boolean mIsPremium;
|
||||
|
||||
/**
|
||||
* Base constructor. Takes a key / section name in case the third parameter, the Setting,
|
||||
|
@ -48,7 +46,6 @@ public abstract class SettingsItem {
|
|||
mSetting = setting;
|
||||
mNameId = nameId;
|
||||
mDescriptionId = descriptionId;
|
||||
mIsPremium = (section == Settings.SECTION_PREMIUM);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -93,10 +90,6 @@ public abstract class SettingsItem {
|
|||
return mDescriptionId;
|
||||
}
|
||||
|
||||
public boolean isPremium() {
|
||||
return mIsPremium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by {@link SettingsAdapter}'s onCreateViewHolder()
|
||||
* method to determine which type of ViewHolder should be created.
|
||||
|
|
|
@ -26,7 +26,6 @@ import com.google.android.material.appbar.MaterialToolbar;
|
|||
import org.citra.citra_emu.NativeLibrary;
|
||||
import org.citra.citra_emu.R;
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization;
|
||||
import org.citra.citra_emu.utils.DirectoryStateReceiver;
|
||||
import org.citra.citra_emu.utils.EmulationMenuSettings;
|
||||
import org.citra.citra_emu.utils.InsetsHelper;
|
||||
import org.citra.citra_emu.utils.ThemeUtil;
|
||||
|
@ -48,8 +47,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
|
|||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
ThemeUtil.applyTheme(this);
|
||||
|
||||
ThemeUtil.INSTANCE.setTheme(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
|
||||
|
@ -109,7 +107,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
|
|||
mPresenter.onStop(isFinishing());
|
||||
|
||||
// Update framebuffer layout when closing the settings
|
||||
NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
|
||||
NativeLibrary.INSTANCE.notifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
|
||||
getWindowManager().getDefaultDisplay().getRotation());
|
||||
}
|
||||
|
||||
|
@ -147,19 +145,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
|
|||
return duration != 0 && transition != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) {
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
receiver,
|
||||
filter);
|
||||
DirectoryInitialization.start(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) {
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showLoading() {
|
||||
if (dialog == null) {
|
||||
|
|
|
@ -11,7 +11,6 @@ import org.citra.citra_emu.features.settings.model.Settings;
|
|||
import org.citra.citra_emu.features.settings.utils.SettingsFile;
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization;
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
|
||||
import org.citra.citra_emu.utils.DirectoryStateReceiver;
|
||||
import org.citra.citra_emu.utils.Log;
|
||||
import org.citra.citra_emu.utils.ThemeUtil;
|
||||
|
||||
|
@ -24,8 +23,6 @@ public final class SettingsActivityPresenter {
|
|||
|
||||
private boolean mShouldSave;
|
||||
|
||||
private DirectoryStateReceiver directoryStateReceiver;
|
||||
|
||||
private String menuTag;
|
||||
private String gameId;
|
||||
|
||||
|
@ -64,30 +61,7 @@ public final class SettingsActivityPresenter {
|
|||
if (configFile == null || !configFile.exists()) {
|
||||
Log.error("Citra config file could not be found!");
|
||||
}
|
||||
if (DirectoryInitialization.areCitraDirectoriesReady()) {
|
||||
loadSettingsUI();
|
||||
} else {
|
||||
mView.showLoading();
|
||||
IntentFilter statusIntentFilter = new IntentFilter(
|
||||
DirectoryInitialization.BROADCAST_ACTION);
|
||||
|
||||
directoryStateReceiver =
|
||||
new DirectoryStateReceiver(directoryInitializationState ->
|
||||
{
|
||||
if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
|
||||
mView.hideLoading();
|
||||
loadSettingsUI();
|
||||
} else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
|
||||
mView.showPermissionNeededHint();
|
||||
mView.hideLoading();
|
||||
} else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
|
||||
mView.showExternalStorageNotMountedHint();
|
||||
mView.hideLoading();
|
||||
}
|
||||
});
|
||||
|
||||
mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter);
|
||||
}
|
||||
loadSettingsUI();
|
||||
}
|
||||
|
||||
public void setSettings(Settings settings) {
|
||||
|
@ -99,17 +73,12 @@ public final class SettingsActivityPresenter {
|
|||
}
|
||||
|
||||
public void onStop(boolean finishing) {
|
||||
if (directoryStateReceiver != null) {
|
||||
mView.stopListeningToDirectoryInitializationService(directoryStateReceiver);
|
||||
directoryStateReceiver = null;
|
||||
}
|
||||
|
||||
if (mSettings != null && finishing && mShouldSave) {
|
||||
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...");
|
||||
mSettings.saveSettings(mView);
|
||||
}
|
||||
|
||||
NativeLibrary.ReloadSettings();
|
||||
NativeLibrary.INSTANCE.reloadSettings();
|
||||
}
|
||||
|
||||
public void onSettingChanged() {
|
||||
|
|
|
@ -3,7 +3,6 @@ package org.citra.citra_emu.features.settings.ui;
|
|||
import android.content.IntentFilter;
|
||||
|
||||
import org.citra.citra_emu.features.settings.model.Settings;
|
||||
import org.citra.citra_emu.utils.DirectoryStateReceiver;
|
||||
|
||||
/**
|
||||
* Abstraction for the Activity that manages SettingsFragments.
|
||||
|
@ -85,19 +84,4 @@ public interface SettingsActivityView {
|
|||
* Show a hint to the user that the app needs the external storage to be mounted
|
||||
*/
|
||||
void showExternalStorageNotMountedHint();
|
||||
|
||||
/**
|
||||
* Start the DirectoryInitialization and listen for the result.
|
||||
*
|
||||
* @param receiver the broadcast receiver for the DirectoryInitialization
|
||||
* @param filter the Intent broadcasts to be received.
|
||||
*/
|
||||
void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter);
|
||||
|
||||
/**
|
||||
* Stop listening to the DirectoryInitialization.
|
||||
*
|
||||
* @param receiver The broadcast receiver to unregister.
|
||||
*/
|
||||
void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver);
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import org.citra.citra_emu.features.settings.model.StringSetting;
|
|||
import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
|
||||
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.SliderSetting;
|
||||
|
@ -34,12 +33,10 @@ import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHo
|
|||
import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder;
|
||||
import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder;
|
||||
import org.citra.citra_emu.ui.main.MainActivity;
|
||||
import org.citra.citra_emu.utils.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
@ -97,10 +94,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
view = inflater.inflate(R.layout.list_item_setting, parent, false);
|
||||
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:
|
||||
Log.error("[SettingsAdapter] Invalid view type: " + viewType);
|
||||
return null;
|
||||
|
@ -146,17 +139,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
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) {
|
||||
mClickedItem = item;
|
||||
|
||||
|
@ -170,28 +152,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
|
||||
public void onSingleChoiceClick(SingleChoiceSetting 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 onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) {
|
||||
mClickedPosition = position;
|
||||
|
||||
if (!item.isPremium() || MainActivity.isPremiumActive()) {
|
||||
// Setting is either not Premium, or the user has Premium
|
||||
onSingleChoiceClick(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// User needs Premium, invoke the billing flow
|
||||
MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item));
|
||||
onSingleChoiceClick(item);
|
||||
}
|
||||
|
||||
public void onStringSingleChoiceClick(StringSingleChoiceSetting item) {
|
||||
|
@ -205,15 +166,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
|
||||
public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) {
|
||||
mClickedPosition = position;
|
||||
|
||||
if (!item.isPremium() || MainActivity.isPremiumActive()) {
|
||||
// Setting is either not Premium, or the user has Premium
|
||||
onStringSingleChoiceClick(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// User needs Premium, invoke the billing flow
|
||||
MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item));
|
||||
onStringSingleChoiceClick(item);
|
||||
}
|
||||
|
||||
DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog();
|
||||
|
@ -351,10 +304,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
mView.putSetting(setting);
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
} else if (mClickedItem instanceof PremiumSingleChoiceSetting) {
|
||||
PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem;
|
||||
scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which));
|
||||
closeDialog();
|
||||
} else if (mClickedItem instanceof StringSingleChoiceSetting) {
|
||||
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) {
|
||||
int value = item.getSelectedValue();
|
||||
int valuesId = item.getValuesId();
|
||||
|
@ -447,25 +385,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
|
|||
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
|
||||
public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) {
|
||||
mSliderProgress = (int) value;
|
||||
|
|
|
@ -17,8 +17,6 @@ import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
|
|||
import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.HeaderSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.PremiumHeader;
|
||||
import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
|
||||
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.SliderSetting;
|
||||
|
@ -107,9 +105,6 @@ public final class SettingsFragmentPresenter {
|
|||
case SettingsFile.FILE_NAME_CONFIG:
|
||||
addConfigSettings(sl);
|
||||
break;
|
||||
case Settings.SECTION_PREMIUM:
|
||||
addPremiumSettings(sl);
|
||||
break;
|
||||
case Settings.SECTION_CORE:
|
||||
addGeneralSettings(sl);
|
||||
break;
|
||||
|
@ -143,7 +138,6 @@ public final class SettingsFragmentPresenter {
|
|||
private void addConfigSettings(ArrayList<SettingsItem> sl) {
|
||||
mView.getActivity().setTitle(R.string.preferences_settings);
|
||||
|
||||
sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM));
|
||||
sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE));
|
||||
sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM));
|
||||
sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA));
|
||||
|
@ -153,25 +147,6 @@ public final class SettingsFragmentPresenter {
|
|||
sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG));
|
||||
}
|
||||
|
||||
private void addPremiumSettings(ArrayList<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) {
|
||||
mView.getActivity().setTitle(R.string.preferences_general);
|
||||
|
||||
|
@ -367,6 +342,7 @@ public final class SettingsFragmentPresenter {
|
|||
Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D);
|
||||
Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D);
|
||||
Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE);
|
||||
Setting textureFilterName = rendererSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME);
|
||||
SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT);
|
||||
Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE);
|
||||
Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT);
|
||||
|
@ -385,6 +361,7 @@ public final class SettingsFragmentPresenter {
|
|||
sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode));
|
||||
sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul));
|
||||
sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache));
|
||||
sl.add(new SingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_RENDERER, R.string.texture_filter_name, 0, R.array.textureFilterNames, R.array.textureFilterValues, 0, textureFilterName));
|
||||
|
||||
sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0));
|
||||
sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ import android.view.View;
|
|||
import android.widget.TextView;
|
||||
|
||||
import org.citra.citra_emu.R;
|
||||
import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.SettingsItem;
|
||||
import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
|
||||
import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
|
||||
|
@ -46,17 +45,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
|
|||
mTextSettingDescription.setText(choices[i]);
|
||||
}
|
||||
}
|
||||
} else if (item instanceof PremiumSingleChoiceSetting) {
|
||||
PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item;
|
||||
int selected = setting.getSelectedValue();
|
||||
Resources resMgr = mTextSettingDescription.getContext().getResources();
|
||||
String[] choices = resMgr.getStringArray(setting.getChoicesId());
|
||||
int[] values = resMgr.getIntArray(setting.getValuesId());
|
||||
for (int i = 0; i < values.length; ++i) {
|
||||
if (values[i] == selected) {
|
||||
mTextSettingDescription.setText(choices[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mTextSettingDescription.setVisibility(View.GONE);
|
||||
}
|
||||
|
@ -67,8 +55,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder {
|
|||
int position = getAdapterPosition();
|
||||
if (mItem instanceof SingleChoiceSetting) {
|
||||
getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position);
|
||||
} else if (mItem instanceof PremiumSingleChoiceSetting) {
|
||||
getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position);
|
||||
} else if (mItem instanceof StringSingleChoiceSetting) {
|
||||
getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position);
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ public final class SettingsFile {
|
|||
|
||||
public static final String KEY_DESIGN = "design";
|
||||
|
||||
public static final String KEY_PREMIUM = "premium";
|
||||
|
||||
public static final String KEY_GRAPHICS_API = "graphics_api";
|
||||
public static final String KEY_SPIRV_SHADER_GEN = "spirv_shader_gen";
|
||||
|
@ -160,7 +159,7 @@ public final class SettingsFile {
|
|||
BufferedReader reader = null;
|
||||
|
||||
try {
|
||||
Context context = CitraApplication.getAppContext();
|
||||
Context context = CitraApplication.Companion.getAppContext();
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
|
||||
reader = new BufferedReader(new InputStreamReader(inputStream));
|
||||
|
||||
|
@ -226,7 +225,7 @@ public final class SettingsFile {
|
|||
DocumentFile ini = getSettingsFile(fileName);
|
||||
|
||||
try {
|
||||
Context context = CitraApplication.getAppContext();
|
||||
Context context = CitraApplication.Companion.getAppContext();
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri());
|
||||
Wini writer = new Wini(inputStream);
|
||||
|
||||
|
@ -242,24 +241,7 @@ public final class SettingsFile {
|
|||
outputStream.close();
|
||||
} catch (IOException e) {
|
||||
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage());
|
||||
view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static void saveCustomGameSettings(final String gameId, final HashMap<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());
|
||||
}
|
||||
view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -280,13 +262,13 @@ public final class SettingsFile {
|
|||
}
|
||||
|
||||
public static DocumentFile getSettingsFile(String fileName) {
|
||||
DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
|
||||
DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory()));
|
||||
DocumentFile configDirectory = root.findFile("config");
|
||||
return configDirectory.findFile(fileName + ".ini");
|
||||
}
|
||||
|
||||
private static DocumentFile getCustomGameSettingsFile(String gameId) {
|
||||
DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory()));
|
||||
DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory()));
|
||||
DocumentFile configDirectory = root.findFile("GameSettings");
|
||||
return configDirectory.findFile(gameId + ".ini");
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -27,7 +27,6 @@ import org.citra.citra_emu.activities.EmulationActivity;
|
|||
import org.citra.citra_emu.overlay.InputOverlay;
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization;
|
||||
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
|
||||
import org.citra.citra_emu.utils.DirectoryStateReceiver;
|
||||
import org.citra.citra_emu.utils.EmulationMenuSettings;
|
||||
import org.citra.citra_emu.utils.Log;
|
||||
|
||||
|
@ -42,8 +41,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
|
||||
private EmulationState mEmulationState;
|
||||
|
||||
private DirectoryStateReceiver directoryStateReceiver;
|
||||
|
||||
private EmulationActivity activity;
|
||||
|
||||
private TextView mPerfStats;
|
||||
|
@ -65,7 +62,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
|
||||
if (context instanceof EmulationActivity) {
|
||||
activity = (EmulationActivity) context;
|
||||
NativeLibrary.setEmulationActivity((EmulationActivity) context);
|
||||
NativeLibrary.INSTANCE.setEmulationActivity((EmulationActivity) context);
|
||||
} else {
|
||||
throw new IllegalStateException("EmulationFragment must have EmulationActivity parent");
|
||||
}
|
||||
|
@ -116,20 +113,11 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
public void onResume() {
|
||||
super.onResume();
|
||||
Choreographer.getInstance().postFrameCallback(this);
|
||||
if (DirectoryInitialization.areCitraDirectoriesReady()) {
|
||||
mEmulationState.run(activity.isActivityRecreated());
|
||||
} else {
|
||||
setupCitraDirectoriesThenStartEmulation();
|
||||
}
|
||||
mEmulationState.run(activity.isActivityRecreated());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (directoryStateReceiver != null) {
|
||||
LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver);
|
||||
directoryStateReceiver = null;
|
||||
}
|
||||
|
||||
if (mEmulationState.isRunning()) {
|
||||
mEmulationState.pause();
|
||||
}
|
||||
|
@ -140,39 +128,10 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
NativeLibrary.clearEmulationActivity();
|
||||
NativeLibrary.INSTANCE.clearEmulationActivity();
|
||||
super.onDetach();
|
||||
}
|
||||
|
||||
private void setupCitraDirectoriesThenStartEmulation() {
|
||||
IntentFilter statusIntentFilter = new IntentFilter(
|
||||
DirectoryInitialization.BROADCAST_ACTION);
|
||||
|
||||
directoryStateReceiver =
|
||||
new DirectoryStateReceiver(directoryInitializationState ->
|
||||
{
|
||||
if (directoryInitializationState ==
|
||||
DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
|
||||
mEmulationState.run(activity.isActivityRecreated());
|
||||
} else if (directoryInitializationState ==
|
||||
DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
|
||||
Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
} else if (directoryInitializationState ==
|
||||
DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
|
||||
Toast.makeText(getContext(), R.string.external_storage_not_mounted,
|
||||
Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
}
|
||||
});
|
||||
|
||||
// Registers the DirectoryStateReceiver and its intent filters
|
||||
LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
|
||||
directoryStateReceiver,
|
||||
statusIntentFilter);
|
||||
DirectoryInitialization.start(getActivity());
|
||||
}
|
||||
|
||||
public void refreshInputOverlay() {
|
||||
mInputOverlay.refreshControls();
|
||||
}
|
||||
|
@ -195,7 +154,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
|
||||
perfStatsUpdater = () ->
|
||||
{
|
||||
final double[] perfStats = NativeLibrary.GetPerfStats();
|
||||
final double[] perfStats = NativeLibrary.INSTANCE.getPerfStats();
|
||||
if (perfStats[FPS] > 0) {
|
||||
mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5),
|
||||
(int) (perfStats[SPEED] * 100.0 + 0.5)));
|
||||
|
@ -235,7 +194,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
@Override
|
||||
public void doFrame(long frameTimeNanos) {
|
||||
Choreographer.getInstance().postFrameCallback(this);
|
||||
NativeLibrary.DoFrame();
|
||||
NativeLibrary.INSTANCE.doFrame();
|
||||
}
|
||||
|
||||
public void stopEmulation() {
|
||||
|
@ -286,7 +245,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
if (state != State.STOPPED) {
|
||||
Log.debug("[EmulationFragment] Stopping emulation.");
|
||||
state = State.STOPPED;
|
||||
NativeLibrary.StopEmulation();
|
||||
NativeLibrary.INSTANCE.stopEmulation();
|
||||
} else {
|
||||
Log.warning("[EmulationFragment] Stop called while already stopped.");
|
||||
}
|
||||
|
@ -300,8 +259,8 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
Log.debug("[EmulationFragment] Pausing emulation.");
|
||||
|
||||
// Release the surface before pausing, since emulation has to be running for that.
|
||||
NativeLibrary.SurfaceDestroyed();
|
||||
NativeLibrary.PauseEmulation();
|
||||
NativeLibrary.INSTANCE.surfaceDestroyed();
|
||||
NativeLibrary.INSTANCE.pauseEmulation();
|
||||
} else {
|
||||
Log.warning("[EmulationFragment] Pause called while already paused.");
|
||||
}
|
||||
|
@ -309,7 +268,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
|
||||
public synchronized void run(boolean isActivityRecreated) {
|
||||
if (isActivityRecreated) {
|
||||
if (NativeLibrary.IsRunning()) {
|
||||
if (NativeLibrary.INSTANCE.isRunning()) {
|
||||
state = State.PAUSED;
|
||||
}
|
||||
} else {
|
||||
|
@ -340,7 +299,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
Log.debug("[EmulationFragment] Surface destroyed.");
|
||||
|
||||
if (state == State.RUNNING) {
|
||||
NativeLibrary.SurfaceDestroyed();
|
||||
NativeLibrary.INSTANCE.surfaceDestroyed();
|
||||
state = State.PAUSED;
|
||||
} else if (state == State.PAUSED) {
|
||||
Log.warning("[EmulationFragment] Surface cleared while emulation paused.");
|
||||
|
@ -353,18 +312,18 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
|
|||
private void runWithValidSurface() {
|
||||
mRunWhenSurfaceIsValid = false;
|
||||
if (state == State.STOPPED) {
|
||||
NativeLibrary.SurfaceChanged(mSurface);
|
||||
NativeLibrary.INSTANCE.surfaceChanged(mSurface);
|
||||
Thread mEmulationThread = new Thread(() ->
|
||||
{
|
||||
Log.debug("[EmulationFragment] Starting emulation thread.");
|
||||
NativeLibrary.Run(mGamePath);
|
||||
NativeLibrary.INSTANCE.run(mGamePath);
|
||||
}, "NativeEmulation");
|
||||
mEmulationThread.start();
|
||||
|
||||
} else if (state == State.PAUSED) {
|
||||
Log.debug("[EmulationFragment] Resuming emulation.");
|
||||
NativeLibrary.SurfaceChanged(mSurface);
|
||||
NativeLibrary.UnPauseEmulation();
|
||||
NativeLibrary.INSTANCE.surfaceChanged(mSurface);
|
||||
NativeLibrary.INSTANCE.unPauseEmulation();
|
||||
} else {
|
||||
Log.debug("[EmulationFragment] Bug, run called while already running.");
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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("")
|
||||
)
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -347,7 +347,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
|
|||
if (!button.updateStatus(event)) {
|
||||
continue;
|
||||
}
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus());
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus());
|
||||
shouldUpdateView = true;
|
||||
}
|
||||
|
||||
|
@ -355,10 +355,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
|
|||
if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) {
|
||||
continue;
|
||||
}
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus());
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus());
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus());
|
||||
NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus());
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus());
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus());
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus());
|
||||
NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus());
|
||||
shouldUpdateView = true;
|
||||
}
|
||||
|
||||
|
@ -367,7 +367,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
|
|||
continue;
|
||||
}
|
||||
int axisID = joystick.getJoystickId();
|
||||
NativeLibrary
|
||||
NativeLibrary.INSTANCE
|
||||
.onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis());
|
||||
shouldUpdateView = true;
|
||||
}
|
||||
|
@ -390,7 +390,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
|
|||
boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP;
|
||||
|
||||
if (isActionDown && !isTouchInputConsumed(pointerId)) {
|
||||
NativeLibrary.onTouchEvent(xPosition, yPosition, true);
|
||||
NativeLibrary.INSTANCE.onTouchEvent(xPosition, yPosition, true);
|
||||
}
|
||||
|
||||
if (isActionMove) {
|
||||
|
@ -399,12 +399,12 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener {
|
|||
if (isTouchInputConsumed(fingerId)) {
|
||||
continue;
|
||||
}
|
||||
NativeLibrary.onTouchMoved(xPosition, yPosition);
|
||||
NativeLibrary.INSTANCE.onTouchMoved(xPosition, yPosition);
|
||||
}
|
||||
}
|
||||
|
||||
if (isActionUp && !isTouchInputConsumed(pointerId)) {
|
||||
NativeLibrary.onTouchEvent(0, 0, false);
|
||||
NativeLibrary.INSTANCE.onTouchEvent(0, 0, false);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import android.app.NotificationManager;
|
|||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -13,6 +14,7 @@ import androidx.work.ForegroundInfo;
|
|||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import org.citra.citra_emu.NativeLibrary.InstallStatus;
|
||||
import org.citra.citra_emu.R;
|
||||
|
||||
public class CiaInstallWorker extends Worker {
|
||||
|
@ -56,15 +58,6 @@ public class CiaInstallWorker extends Worker {
|
|||
super(context, params);
|
||||
}
|
||||
|
||||
enum InstallStatus {
|
||||
Success,
|
||||
ErrorFailedToOpenFile,
|
||||
ErrorFileNotFound,
|
||||
ErrorAborted,
|
||||
ErrorInvalid,
|
||||
ErrorEncrypted,
|
||||
}
|
||||
|
||||
private void notifyInstallStatus(String filename, InstallStatus status) {
|
||||
switch(status){
|
||||
case Success:
|
||||
|
@ -126,10 +119,10 @@ public class CiaInstallWorker extends Worker {
|
|||
|
||||
int i = 0;
|
||||
for (String file : selectedFiles) {
|
||||
String filename = FileUtil.getFilename(mContext, file);
|
||||
String filename = FileUtil.getFilename(Uri.parse(file));
|
||||
mInstallProgressBuilder.setContentText(mContext.getString(
|
||||
R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length));
|
||||
InstallStatus res = InstallCIA(file);
|
||||
InstallStatus res = installCIA(file);
|
||||
notifyInstallStatus(filename, res);
|
||||
}
|
||||
mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID);
|
||||
|
@ -156,5 +149,5 @@ public class CiaInstallWorker extends Worker {
|
|||
return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build());
|
||||
}
|
||||
|
||||
private native InstallStatus InstallCIA(String path);
|
||||
private native InstallStatus installCIA(String path);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import android.view.View;
|
|||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
@ -25,6 +26,7 @@ import org.citra.citra_emu.utils.Log;
|
|||
|
||||
import java.util.Objects;
|
||||
|
||||
@Keep
|
||||
public class DiskShaderCacheProgress {
|
||||
|
||||
// Equivalent to VideoCore::LoadCallbackStage
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = "/"
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ import android.preference.PreferenceManager;
|
|||
import org.citra.citra_emu.CitraApplication;
|
||||
|
||||
public class EmulationMenuSettings {
|
||||
private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
|
||||
private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext());
|
||||
|
||||
// These must match what is defined in src/common/settings.h
|
||||
public static final int LayoutOption_Default = 0;
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -8,6 +8,9 @@ import org.citra.citra_emu.BuildConfig;
|
|||
* levels in release builds.
|
||||
*/
|
||||
public final class Log {
|
||||
// Tracks whether we should share the old log or the current log
|
||||
public static boolean gameLaunched = false;
|
||||
|
||||
private static final String TAG = "Citra Frontend";
|
||||
|
||||
private Log() {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -2,44 +2,14 @@ package org.citra.citra_emu.utils;
|
|||
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.squareup.picasso.Picasso;
|
||||
|
||||
import org.citra.citra_emu.CitraApplication;
|
||||
import org.citra.citra_emu.R;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class PicassoUtils {
|
||||
private static boolean mPicassoInitialized = false;
|
||||
|
||||
public static void init() {
|
||||
if (mPicassoInitialized) {
|
||||
return;
|
||||
}
|
||||
Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext())
|
||||
.addRequestHandler(new GameIconRequestHandler())
|
||||
.build();
|
||||
|
||||
Picasso.setSingletonInstance(picassoInstance);
|
||||
mPicassoInitialized = true;
|
||||
}
|
||||
|
||||
public static void loadGameIcon(ImageView imageView, String gamePath) {
|
||||
Picasso
|
||||
.get()
|
||||
.load(Uri.parse(gamePath))
|
||||
.fit()
|
||||
.centerInside()
|
||||
.config(Bitmap.Config.RGB_565)
|
||||
.error(R.drawable.no_icon)
|
||||
.transform(new PicassoRoundedCornersTransformation())
|
||||
.into(imageView);
|
||||
}
|
||||
|
||||
// Blocking call. Load image from file and crop/resize it to fit in width x height.
|
||||
@Nullable
|
||||
public static Bitmap LoadBitmapFromFile(String uri, int width, int height) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
Reference in New Issue