mirror of
				https://github.com/PabloMK7/citra.git
				synced 2025-10-31 05:40:04 +00:00 
			
		
		
		
	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
					
				
					 182 changed files with 10511 additions and 5183 deletions
				
			
		|  | @ -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() | ||||
|  |  | |||
							
								
								
									
										40
									
								
								src/android/app/proguard-rules.pro
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										40
									
								
								src/android/app/proguard-rules.pro
									
										
									
									
										vendored
									
									
								
							|  | @ -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
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue