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