diff --git a/src/LibRyujinx/Android/JniExportedMethods.cs b/src/LibRyujinx/Android/JniExportedMethods.cs index e3a9ab860..255adb456 100644 --- a/src/LibRyujinx/Android/JniExportedMethods.cs +++ b/src/LibRyujinx/Android/JniExportedMethods.cs @@ -18,6 +18,7 @@ using Silk.NET.Vulkan.Extensions.KHR; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -40,6 +41,9 @@ namespace LibRyujinx [DllImport("libryujinxjni")] private extern static JStringLocalRef createString(JEnvRef jEnv, IntPtr ch); + [DllImport("libryujinxjni")] + private extern static void pushString(string ch); + [DllImport("libryujinxjni")] internal extern static void setRenderingThread(); @@ -95,6 +99,12 @@ namespace LibRyujinx return s; } + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceReloadFilesystem")] + public static void JniReloadFileSystem() + { + SwitchDevice?.ReloadFileSystem(); + } + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceInitialize")] public static JBoolean JniInitializeDeviceNative(JEnvRef jEnv, JObjectLocalRef jObj, @@ -503,6 +513,89 @@ namespace LibRyujinx return ConnectGamepad(index); } + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userGetOpenedUser")] + public static void JniGetOpenedUser(JEnvRef jEnv, JObjectLocalRef jObj) + { + var userId = GetOpenedUser(); + + pushString(userId); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userGetUserPicture")] + public static JStringLocalRef JniGetUserPicture(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + + return CreateString(jEnv, GetUserPicture(userId)); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userSetUserPicture")] + public static void JniGetUserPicture(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr, JStringLocalRef picturePtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + var picture = GetString(jEnv, picturePtr) ?? ""; + + SetUserPicture(userId, picture); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userGetUserName")] + public static JStringLocalRef JniGetUserName(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + + return CreateString(jEnv, GetUserName(userId)); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userSetUserName")] + public static void JniSetUserName(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr, JStringLocalRef userNamePtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + var userName = GetString(jEnv, userNamePtr) ?? ""; + + SetUserName(userId, userName); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userGetAllUsers")] + public static JArrayLocalRef JniGetAllUsers(JEnvRef jEnv, JObjectLocalRef jObj) + { + var users = GetAllUsers(); + + return CreateStringArray(jEnv, users.ToList()); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userAddUser")] + public static void JniAddUser(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userNamePtr, JStringLocalRef picturePtr) + { + var userName = GetString(jEnv, userNamePtr) ?? ""; + var picture = GetString(jEnv, picturePtr) ?? ""; + + AddUser(userName, picture); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userDeleteUser")] + public static void JniDeleteUser(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + + DeleteUser(userId); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userOpenUser")] + public static void JniOpenUser(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + + OpenUser(userId); + } + + [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_userCloseUser")] + public static void JniCloseUser(JEnvRef jEnv, JObjectLocalRef jObj, JStringLocalRef userIdPtr) + { + var userId = GetString(jEnv, userIdPtr) ?? ""; + + CloseUser(userId); + } + private static FileStream OpenFile(int descriptor) { var safeHandle = new SafeFileHandle(descriptor, false); diff --git a/src/RyujinxAndroid/app/build.gradle b/src/RyujinxAndroid/app/build.gradle index c1e90b005..a2ecc7554 100644 --- a/src/RyujinxAndroid/app/build.gradle +++ b/src/RyujinxAndroid/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "org.ryujinx.android" minSdk 30 targetSdk 33 - versionCode 1 - versionName "1.0" + versionCode 10001 + versionName '1.0.1' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -99,6 +99,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.0" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2' implementation 'com.google.code.gson:gson:2.10.1' + implementation 'net.lingala.zip4j:zip4j:2.11.5' implementation("br.com.devsrsouza.compose.icons:css-gg:1.1.0") implementation "io.coil-kt:coil-compose:2.4.0" testImplementation 'junit:junit:4.13.2' diff --git a/src/RyujinxAndroid/app/src/main/AndroidManifest.xml b/src/RyujinxAndroid/app/src/main/AndroidManifest.xml index 4a05d62e4..73e7d70d3 100644 --- a/src/RyujinxAndroid/app/src/main/AndroidManifest.xml +++ b/src/RyujinxAndroid/app/src/main/AndroidManifest.xml @@ -24,7 +24,6 @@ android:icon="@mipmap/ic_launcher" android:isGame="true" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.RyujinxAndroid" tools:targetApi="31"> diff --git a/src/RyujinxAndroid/app/src/main/cpp/ryuijnx.h b/src/RyujinxAndroid/app/src/main/cpp/ryuijnx.h index b26687b44..87e7c3124 100644 --- a/src/RyujinxAndroid/app/src/main/cpp/ryuijnx.h +++ b/src/RyujinxAndroid/app/src/main/cpp/ryuijnx.h @@ -45,5 +45,6 @@ long _currentRenderingThreadId = 0; JavaVM* _vm = nullptr; jobject _mainActivity = nullptr; jclass _mainActivityClass = nullptr; +std::string _currentString = ""; #endif //RYUJINXNATIVE_RYUIJNX_H diff --git a/src/RyujinxAndroid/app/src/main/cpp/ryujinx.cpp b/src/RyujinxAndroid/app/src/main/cpp/ryujinx.cpp index 278c176b5..095f462db 100644 --- a/src/RyujinxAndroid/app/src/main/cpp/ryujinx.cpp +++ b/src/RyujinxAndroid/app/src/main/cpp/ryujinx.cpp @@ -311,3 +311,25 @@ JNIEXPORT jstring JNICALL Java_org_ryujinx_android_NativeHelpers_getProgressInfo(JNIEnv *env, jobject thiz) { return createStringFromStdString(env, progressInfo); } + +extern "C" +JNIEXPORT jstring JNICALL +Java_org_ryujinx_android_NativeHelpers_popStringJava(JNIEnv *env, jobject thiz) { + return createStringFromStdString(env, _currentString); +} +extern "C" +JNIEXPORT void JNICALL +Java_org_ryujinx_android_NativeHelpers_pushStringJava(JNIEnv *env, jobject thiz, jstring string) { + _currentString = getStringPointer(env, string); +} + + +extern "C" +void pushString(char* str){ + _currentString = str; +} + +extern "C" +const char* popString(){ + return _currentString.c_str(); +} diff --git a/src/RyujinxAndroid/app/src/main/ic_launcher-playstore.png b/src/RyujinxAndroid/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..e27d7167c Binary files /dev/null and b/src/RyujinxAndroid/app/src/main/ic_launcher-playstore.png differ diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/BaseActivity.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/BaseActivity.kt new file mode 100644 index 000000000..d333a3ecc --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/BaseActivity.kt @@ -0,0 +1,16 @@ +package org.ryujinx.android + +import android.os.Bundle +import android.os.PersistableBundle +import androidx.activity.ComponentActivity + +abstract class BaseActivity : ComponentActivity() { + companion object{ + val crashHandler = CrashHandler() + } + + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + Thread.setDefaultUncaughtExceptionHandler(crashHandler) + super.onCreate(savedInstanceState, persistentState) + } +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt new file mode 100644 index 000000000..2d6503020 --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt @@ -0,0 +1,13 @@ +package org.ryujinx.android + +import java.io.File +import java.lang.Thread.UncaughtExceptionHandler + +class CrashHandler : UncaughtExceptionHandler { + var crashLog : String = "" + override fun uncaughtException(t: Thread, e: Throwable) { + crashLog += e.toString() + "\n" + + File(MainActivity.AppPath + "${File.separator}crash.log").writeText(crashLog) + } +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/GameActivity.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/GameActivity.kt index 37113912f..1670e879a 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/GameActivity.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/GameActivity.kt @@ -6,7 +6,6 @@ import android.content.pm.ActivityInfo import android.os.Bundle import android.view.KeyEvent import android.view.MotionEvent -import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -51,7 +50,7 @@ import org.ryujinx.android.viewmodels.QuickSettings import kotlin.math.abs import kotlin.math.roundToInt -class GameActivity : ComponentActivity() { +class GameActivity : BaseActivity() { private var physicalControllerManager: PhysicalControllerManager = PhysicalControllerManager(this) @@ -355,6 +354,7 @@ class GameActivity : ComponentActivity() { .padding(16.dp) ) { Button(onClick = { + showBackNotice.value = false mainViewModel.closeGame() setFullScreen(false) finishActivity(0) diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Helpers.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Helpers.kt index 780a8fa6d..aef544c7a 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Helpers.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Helpers.kt @@ -7,9 +7,22 @@ import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore +import androidx.compose.runtime.MutableState +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorageHelper +import com.anggrayudi.storage.callback.FileCallback +import com.anggrayudi.storage.file.copyFileTo +import com.anggrayudi.storage.file.openInputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.lingala.zip4j.io.inputstream.ZipInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream class Helpers { - companion object{ + companion object { fun getPath(context: Context, uri: Uri): String? { // DocumentProvider @@ -25,7 +38,10 @@ class Helpers { } else if (isDownloadsDocument(uri)) { val id = DocumentsContract.getDocumentId(uri) - val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), + java.lang.Long.valueOf(id) + ) return getDataColumn(context, contentUri, null, null) } else if (isMediaDocument(uri)) { val docId = DocumentsContract.getDocumentId(uri) @@ -36,9 +52,11 @@ class Helpers { "image" -> { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI } + "video" -> { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI } + "audio" -> { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } @@ -54,13 +72,81 @@ class Helpers { } return null } + fun copyToData( + file: DocumentFile, path: String, storageHelper: SimpleStorageHelper, + isCopying: MutableState, + copyProgress: MutableState, + currentProgressName: MutableState, + finish: () -> Unit + ) { + var callback: FileCallback? = object : FileCallback() { + override fun onFailed(errorCode: FileCallback.ErrorCode) { + super.onFailed(errorCode) + File(path).delete() + finish() + } - private fun getDataColumn(context: Context, uri: Uri?, selection: String?, selectionArgs: Array?): String? { + override fun onStart(file: Any, workerThread: Thread): Long { + copyProgress.value = 0f + + (file as DocumentFile)?.apply { + currentProgressName.value = "Copying ${file.name}" + } + return super.onStart(file, workerThread) + } + + override fun onReport(report: Report) { + super.onReport(report) + + if(!isCopying.value) { + Thread.currentThread().interrupt() + } + + copyProgress.value = report.progress / 100f + } + + override fun onCompleted(result: Any) { + super.onCompleted(result) + isCopying.value = false + finish() + } + } + val ioScope = CoroutineScope(Dispatchers.IO) + isCopying.value = true + file.apply { + if (!File(path + "/${file.name}").exists()) { + val f = this + ioScope.launch { + f.copyFileTo( + storageHelper.storage.context, + File(path), + callback = callback!! + ) + + } + } + } + } + + private fun getDataColumn( + context: Context, + uri: Uri?, + selection: String?, + selectionArgs: Array? + ): String? { var cursor: Cursor? = null val column = "_data" val projection = arrayOf(column) try { - cursor = uri?.let { context.contentResolver.query(it, projection, selection, selectionArgs,null) } + cursor = uri?.let { + context.contentResolver.query( + it, + projection, + selection, + selectionArgs, + null + ) + } if (cursor != null && cursor.moveToFirst()) { val column_index: Int = cursor.getColumnIndexOrThrow(column) return cursor.getString(column_index) @@ -82,5 +168,56 @@ class Helpers { private fun isMediaDocument(uri: Uri): Boolean { return "com.android.providers.media.documents" == uri.authority } + + fun importAppData( + file: DocumentFile, + isImporting: MutableState + ) { + isImporting.value = true + try { + MainActivity.StorageHelper?.apply { + val stream = file.openInputStream(storage.context) + stream?.apply { + val folders = listOf("bis", "games", "profiles", "system") + for (f in folders) { + val dir = File(MainActivity.AppPath + "${File.separator}${f}") + if (dir.exists()) { + dir.deleteRecursively() + } + + dir.mkdirs() + } + ZipInputStream(stream).use { zip -> + var count = 0 + while (true) { + val header = zip.nextEntry ?: break + if (!folders.any { header.fileName.startsWith(it) }) { + continue + } + val filePath = + MainActivity.AppPath + File.separator + header.fileName + + if (!header.isDirectory) { + val bos = BufferedOutputStream(FileOutputStream(filePath)) + val bytesIn = ByteArray(4096) + var read: Int = 0 + while (zip.read(bytesIn).also { read = it } > 0) { + bos.write(bytesIn, 0, read) + } + bos.close() + } else { + val dir = File(filePath) + dir.mkdir() + } + } + } + stream.close() + } + } + } finally { + isImporting.value = false + RyujinxNative().deviceReloadFilesystem() + } + } } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Icons.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Icons.kt index 13820e150..54a508298 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Icons.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Icons.kt @@ -23,6 +23,189 @@ class Icons { companion object{ /// Icons exported from https://www.composables.com/icons @Composable + fun playArrow(color: Color): ImageVector { + return remember { + ImageVector.Builder( + name = "play_arrow", + defaultWidth = 40.0.dp, + defaultHeight = 40.0.dp, + viewportWidth = 40.0f, + viewportHeight = 40.0f + ).apply { + path( + fill = SolidColor(color), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(15.542f, 30f) + quadToRelative(-0.667f, 0.458f, -1.334f, 0.062f) + quadToRelative(-0.666f, -0.395f, -0.666f, -1.187f) + verticalLineTo(10.917f) + quadToRelative(0f, -0.75f, 0.666f, -1.146f) + quadToRelative(0.667f, -0.396f, 1.334f, 0.062f) + lineToRelative(14.083f, 9f) + quadToRelative(0.583f, 0.375f, 0.583f, 1.084f) + quadToRelative(0f, 0.708f, -0.583f, 1.083f) + close() + moveToRelative(0.625f, -10.083f) + close() + moveToRelative(0f, 6.541f) + lineToRelative(10.291f, -6.541f) + lineToRelative(-10.291f, -6.542f) + close() + } + }.build() + } + } + @Composable + fun folderOpen(color: Color): ImageVector { + return remember { + ImageVector.Builder( + name = "folder_open", + defaultWidth = 40.0.dp, + defaultHeight = 40.0.dp, + viewportWidth = 40.0f, + viewportHeight = 40.0f + ).apply { + path( + fill = SolidColor(color), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(6.25f, 33.125f) + quadToRelative(-1.083f, 0f, -1.854f, -0.792f) + quadToRelative(-0.771f, -0.791f, -0.771f, -1.875f) + verticalLineTo(9.667f) + quadToRelative(0f, -1.084f, 0.771f, -1.854f) + quadToRelative(0.771f, -0.771f, 1.854f, -0.771f) + horizontalLineToRelative(10.042f) + quadToRelative(0.541f, 0f, 1.041f, 0.208f) + quadToRelative(0.5f, 0.208f, 0.834f, 0.583f) + lineToRelative(1.875f, 1.834f) + horizontalLineTo(33.75f) + quadToRelative(1.083f, 0f, 1.854f, 0.791f) + quadToRelative(0.771f, 0.792f, 0.771f, 1.834f) + horizontalLineTo(18.917f) + lineTo(16.25f, 9.667f) + horizontalLineToRelative(-10f) + verticalLineTo(30.25f) + lineToRelative(3.542f, -13.375f) + quadToRelative(0.25f, -0.875f, 0.979f, -1.396f) + quadToRelative(0.729f, -0.521f, 1.604f, -0.521f) + horizontalLineToRelative(23.25f) + quadToRelative(1.292f, 0f, 2.104f, 1.021f) + quadToRelative(0.813f, 1.021f, 0.438f, 2.271f) + lineToRelative(-3.459f, 12.833f) + quadToRelative(-0.291f, 1f, -1f, 1.521f) + quadToRelative(-0.708f, 0.521f, -1.75f, 0.521f) + close() + moveToRelative(2.708f, -2.667f) + horizontalLineToRelative(23.167f) + lineToRelative(3.417f, -12.875f) + horizontalLineTo(12.333f) + close() + moveToRelative(0f, 0f) + lineToRelative(3.375f, -12.875f) + lineToRelative(-3.375f, 12.875f) + close() + moveToRelative(-2.708f, -15.5f) + verticalLineTo(9.667f) + verticalLineToRelative(5.291f) + close() + } + }.build() + } + } + @Composable + fun gameUpdate(): ImageVector { + val primaryColor = MaterialTheme.colorScheme.primary + return remember { + ImageVector.Builder( + name = "game_update_alt", + defaultWidth = 40.0.dp, + defaultHeight = 40.0.dp, + viewportWidth = 40.0f, + viewportHeight = 40.0f + ).apply { + path( + fill = SolidColor(Color.Black.copy(alpha = 0.5f)), + stroke = SolidColor(primaryColor), + fillAlpha = 1f, + strokeAlpha = 1f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(6.25f, 33.083f) + quadToRelative(-1.083f, 0f, -1.854f, -0.791f) + quadToRelative(-0.771f, -0.792f, -0.771f, -1.834f) + verticalLineTo(9.542f) + quadToRelative(0f, -1.042f, 0.771f, -1.854f) + quadToRelative(0.771f, -0.813f, 1.854f, -0.813f) + horizontalLineToRelative(8.458f) + quadToRelative(0.584f, 0f, 0.959f, 0.396f) + reflectiveQuadToRelative(0.375f, 0.937f) + quadToRelative(0f, 0.584f, -0.375f, 0.959f) + reflectiveQuadToRelative(-0.959f, 0.375f) + horizontalLineTo(6.25f) + verticalLineToRelative(20.916f) + horizontalLineToRelative(27.542f) + verticalLineTo(9.542f) + horizontalLineToRelative(-8.5f) + quadToRelative(-0.584f, 0f, -0.959f, -0.375f) + reflectiveQuadToRelative(-0.375f, -0.959f) + quadToRelative(0f, -0.541f, 0.375f, -0.937f) + reflectiveQuadToRelative(0.959f, -0.396f) + horizontalLineToRelative(8.5f) + quadToRelative(1.041f, 0f, 1.833f, 0.813f) + quadToRelative(0.792f, 0.812f, 0.792f, 1.854f) + verticalLineToRelative(20.916f) + quadToRelative(0f, 1.042f, -0.792f, 1.834f) + quadToRelative(-0.792f, 0.791f, -1.833f, 0.791f) + close() + moveTo(20f, 25f) + quadToRelative(-0.25f, 0f, -0.479f, -0.083f) + quadToRelative(-0.229f, -0.084f, -0.396f, -0.292f) + lineTo(12.75f, 18.25f) + quadToRelative(-0.375f, -0.333f, -0.375f, -0.896f) + quadToRelative(0f, -0.562f, 0.417f, -0.979f) + quadToRelative(0.375f, -0.375f, 0.916f, -0.375f) + quadToRelative(0.542f, 0f, 0.959f, 0.375f) + lineToRelative(4.041f, 4.083f) + verticalLineTo(8.208f) + quadToRelative(0f, -0.541f, 0.375f, -0.937f) + reflectiveQuadTo(20f, 6.875f) + quadToRelative(0.542f, 0f, 0.938f, 0.396f) + quadToRelative(0.395f, 0.396f, 0.395f, 0.937f) + verticalLineToRelative(12.25f) + lineToRelative(4.084f, -4.083f) + quadToRelative(0.333f, -0.333f, 0.875f, -0.333f) + quadToRelative(0.541f, 0f, 0.916f, 0.375f) + quadToRelative(0.417f, 0.416f, 0.417f, 0.958f) + reflectiveQuadToRelative(-0.375f, 0.917f) + lineToRelative(-6.333f, 6.333f) + quadToRelative(-0.209f, 0.208f, -0.438f, 0.292f) + quadTo(20.25f, 25f, 20f, 25f) + close() + } + }.build() + } + } + @Composable fun download(): ImageVector { val primaryColor = MaterialTheme.colorScheme.primary return remember { diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/MainActivity.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/MainActivity.kt index 5f3b95481..29fa53dbf 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/MainActivity.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/MainActivity.kt @@ -4,7 +4,6 @@ import android.os.Build import android.os.Bundle import android.os.Environment import android.view.WindowManager -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -17,7 +16,7 @@ import org.ryujinx.android.viewmodels.MainViewModel import org.ryujinx.android.views.MainView -class MainActivity : ComponentActivity() { +class MainActivity : BaseActivity() { private var _isInit: Boolean = false var storageHelper: SimpleStorageHelper? = null companion object { diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/NativeHelpers.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/NativeHelpers.kt index eead88176..229b61a90 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/NativeHelpers.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/NativeHelpers.kt @@ -28,4 +28,6 @@ class NativeHelpers { external fun setSwapInterval(nativeWindow: Long, swapInterval: Int): Int external fun getProgressInfo() : String external fun getProgressValue() : Float + external fun pushStringJava(string: String) + external fun popStringJava() : String } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt index 98e54e1e4..4c990f094 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt @@ -38,6 +38,7 @@ class RyujinxNative { external fun graphicsRendererSetSize(width: Int, height: Int) external fun graphicsRendererSetVsync(enabled: Boolean) external fun graphicsRendererRunLoop() + external fun deviceReloadFilesystem() external fun inputInitialize(width: Int, height: Int) external fun inputSetClientSize(width: Int, height: Int) external fun inputSetTouchPoint(x: Int, y: Int) @@ -52,4 +53,14 @@ class RyujinxNative { external fun deviceSignalEmulationClose() external fun deviceGetDlcTitleId(path: String, ncaPath: String) : String external fun deviceGetDlcContentList(path: String, titleId: Long) : Array + external fun userGetOpenedUser() + external fun userGetUserPicture(userId: String) : String + external fun userSetUserPicture(userId: String, picture: String) + external fun userGetUserName(userId: String) : String + external fun userSetUserName(userId: String, userName: String) + external fun userGetAllUsers() : Array + external fun userAddUser(username: String, picture: String) + external fun userDeleteUser(userId: String) + external fun userOpenUser(userId: String) + external fun userCloseUser(userId: String) } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt index e8666757f..bbfeb9e8a 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt @@ -68,7 +68,7 @@ class HomeViewModel( ) } - fun reloadGameList() { + fun reloadGameList(ignoreCache: Boolean = false) { var storage = activity?.storageHelper ?: return if(isLoading) @@ -77,27 +77,32 @@ class HomeViewModel( isLoading = true - val files = mutableListOf() + if(!ignoreCache) { + val files = mutableListOf() - thread { - try { - for (file in folder.search(false, DocumentFileType.FILE)) { - if (file.extension == "xci" || file.extension == "nsp") - activity.let { - files.add(GameModel(file, it)) - } + thread { + try { + for (file in folder.search(false, DocumentFileType.FILE)) { + if (file.extension == "xci" || file.extension == "nsp") + activity.let { + files.add(GameModel(file, it)) + } + } + + loadedCache = files.toList() + + isLoading = false + + applyFilter() + } finally { + isLoading = false } - - loadedCache = files.toList() - - isLoading = false - - applyFilter() - } - finally { - isLoading = false } } + else{ + isLoading = false + applyFilter() + } } private fun applyFilter() { @@ -109,6 +114,10 @@ class HomeViewModel( fun setViewList(list: SnapshotStateList) { gameList = list - applyFilter() + reloadGameList(loadedCache.isNotEmpty()) + } + + fun clearLoadedCache(){ + loadedCache = listOf() } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt index c7d64ecc0..0d3850425 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt @@ -37,6 +37,7 @@ class MainViewModel(val activity: MainActivity) { private var progress: MutableState? = null private var progressValue: MutableState? = null private var showLoading: MutableState? = null + private var refreshUser: MutableState? = null var gameHost: GameHost? = null set(value) { field = value @@ -168,6 +169,47 @@ class MainViewModel(val activity: MainActivity) { return true } + fun clearPptcCache(titleId :String){ + if(titleId.isNotEmpty()){ + val basePath = MainActivity.AppPath + "/games/$titleId/cache/cpu" + if(File(basePath).exists()){ + var caches = mutableListOf() + + val mainCache = basePath + "${File.separator}0" + File(mainCache).listFiles()?.forEach { + if(it.isFile && it.name.endsWith(".cache")) + caches.add(it.absolutePath) + } + val backupCache = basePath + "${File.separator}1" + File(backupCache).listFiles()?.forEach { + if(it.isFile && it.name.endsWith(".cache")) + caches.add(it.absolutePath) + } + for(path in caches) + File(path).delete() + } + } + } + + fun purgeShaderCache(titleId :String) { + if(titleId.isNotEmpty()){ + val basePath = MainActivity.AppPath + "/games/$titleId/cache/shader" + if(File(basePath).exists()){ + var caches = mutableListOf() + File(basePath).listFiles()?.forEach { + if(!it.isFile) + it.delete() + else{ + if(it.name.endsWith(".toc") || it.name.endsWith(".data")) + caches.add(it.absolutePath) + } + } + for(path in caches) + File(path).delete() + } + } + } + fun setStatStates( fifo: MutableState, gameFps: MutableState, @@ -213,4 +255,15 @@ class MainViewModel(val activity: MainActivity) { this.progress = progress gameHost?.setProgressStates(showLoading, progressValue, progress) } + + fun setRefreshUserState(refreshUser: MutableState) + { + this.refreshUser = refreshUser + } + + fun requestUserRefresh(){ + refreshUser?.apply { + value = true + } + } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt index 8d3a53456..e42108220 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt @@ -4,27 +4,17 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toLowerCase -import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorageHelper -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.file.DocumentFileCompat -import com.anggrayudi.storage.file.DocumentFileType -import com.anggrayudi.storage.file.copyFileTo -import com.anggrayudi.storage.file.getAbsolutePath import com.google.gson.Gson -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import org.ryujinx.android.Helpers import org.ryujinx.android.MainActivity import java.io.File -import java.util.LinkedList -import java.util.Queue import kotlin.math.max class TitleUpdateViewModel(val titleId: String) { + private var canClose: MutableState? = null private var basePath: String private var updateJsonName = "updates.json" - private var stagingUpdateJsonName = "staging_updates.json" private var storageHelper: SimpleStorageHelper var pathsState: SnapshotStateList? = null @@ -37,32 +27,37 @@ class TitleUpdateViewModel(val titleId: String) { return data?.paths?.apply { - removeAt(index - 1) + val removed = removeAt(index - 1) + File(removed).deleteRecursively() pathsState?.clear() pathsState?.addAll(this) } } - fun Add() { + fun Add( + isCopying: MutableState, + copyProgress: MutableState, + currentProgressName: MutableState + ) { val callBack = storageHelper.onFileSelected storageHelper.onFileSelected = { requestCode, files -> run { storageHelper.onFileSelected = callBack - if(requestCode == UpdateRequestCode) - { + if (requestCode == UpdateRequestCode) { val file = files.firstOrNull() file?.apply { - val path = file.getAbsolutePath(storageHelper.storage.context) - if(path.isNotEmpty()){ - data?.apply { - if(!paths.contains(path)) { - paths.add(path) - pathsState?.clear() - pathsState?.addAll(paths) - } - } - } + // Copy updates to internal data folder + val updatePath = "$basePath/update" + File(updatePath).mkdirs() + Helpers.copyToData( + this, + updatePath, + storageHelper, + isCopying, + copyProgress, + currentProgressName, ::refreshPaths + ) } } } @@ -70,128 +65,60 @@ class TitleUpdateViewModel(val titleId: String) { storageHelper.openFilePicker(UpdateRequestCode) } + fun refreshPaths() { + data?.apply { + val updatePath = "$basePath/update" + val existingPaths = mutableListOf() + File(updatePath).listFiles()?.forEach { existingPaths.add(it.absolutePath) } + + if (!existingPaths.contains(selected)) { + selected = "" + } + pathsState?.clear() + pathsState?.addAll(existingPaths) + paths = existingPaths + canClose?.apply { + value = true + } + } + } + fun save( index: Int, - isCopying: MutableState, - openDialog: MutableState, - copyProgress: MutableState, - currentProgressName: MutableState + openDialog: MutableState ) { data?.apply { + val updatePath = "$basePath/update" this.selected = "" if (paths.isNotEmpty() && index > 0) { val ind = max(index - 1, paths.count() - 1) this.selected = paths[ind] } val gson = Gson() - var json = gson.toJson(this) File(basePath).mkdirs() - File("$basePath/$stagingUpdateJsonName").writeText(json) - // Copy updates to internal data folder - val updatePath = "$basePath/update" - File(updatePath).mkdirs() - - val ioScope = CoroutineScope(Dispatchers.IO) var metadata = TitleUpdateMetadata() - var queue: Queue = LinkedList() + val savedUpdates = mutableListOf() + File(updatePath).listFiles()?.forEach { savedUpdates.add(it.absolutePath) } + metadata.paths = savedUpdates - var callback: FileCallback? = null - - fun copy(path: String) { - isCopying.value = true - val documentFile = DocumentFileCompat.fromFullPath( - storageHelper.storage.context, - path, - DocumentFileType.FILE - ) - documentFile?.apply { - val stagedPath = "$basePath/${name}" - if (!File(stagedPath).exists()) { - var file = this - ioScope.launch { - file.copyFileTo( - storageHelper.storage.context, - File(updatePath), - callback = callback!! - ) - - } - - metadata.paths.add(stagedPath) - } - } + val selectedName = File(selected).name + val newSelectedPath = "$updatePath/$selectedName" + if (File(newSelectedPath).exists()) { + metadata.selected = newSelectedPath } - fun finish() { - val savedUpdates = mutableListOf() - File(updatePath).listFiles()?.forEach { savedUpdates.add(it.absolutePath) } - var missingFiles = - savedUpdates.filter { i -> paths.find { it.endsWith(File(i).name) } == null } - for (path in missingFiles) { - File(path).delete() - } + var json = gson.toJson(metadata) + File("$basePath/$updateJsonName").writeText(json) - val selectedName = File(selected).name - val newSelectedPath = "$updatePath/$selectedName" - if (File(newSelectedPath).exists()) { - metadata.selected = newSelectedPath - } - - json = gson.toJson(metadata) - File("$basePath/$updateJsonName").writeText(json) - - openDialog.value = false - isCopying.value = false - } - callback = object : FileCallback() { - override fun onFailed(errorCode: FileCallback.ErrorCode) { - super.onFailed(errorCode) - } - - override fun onStart(file: Any, workerThread: Thread): Long { - copyProgress.value = 0f - - (file as DocumentFile)?.apply { - currentProgressName.value = "Copying ${file.name}" - } - return super.onStart(file, workerThread) - } - - override fun onReport(report: Report) { - super.onReport(report) - - copyProgress.value = report.progress / 100f - } - - override fun onCompleted(result: Any) { - super.onCompleted(result) - - if (queue.isNotEmpty()) - copy(queue.remove()) - else { - finish() - } - } - } - for (path in paths) { - queue.add(path) - } - - ioScope.launch { - if (queue.isNotEmpty()) { - copy(queue.remove()) - } else { - finish() - } - - } + openDialog.value = false } } - fun setPaths(paths: SnapshotStateList) { + fun setPaths(paths: SnapshotStateList, canClose: MutableState) { pathsState = paths + this.canClose = canClose data?.apply { pathsState?.clear() pathsState?.addAll(this.paths) @@ -203,29 +130,14 @@ class TitleUpdateViewModel(val titleId: String) { init { basePath = MainActivity.AppPath + "/games/" + titleId.toLowerCase(Locale.current) - val stagingJson = "${basePath}/${stagingUpdateJsonName}" jsonPath = "${basePath}/${updateJsonName}" data = TitleUpdateMetadata() - if (File(stagingJson).exists()) { + if (File(jsonPath).exists()) { val gson = Gson() - data = gson.fromJson(File(stagingJson).readText(), TitleUpdateMetadata::class.java) + data = gson.fromJson(File(jsonPath).readText(), TitleUpdateMetadata::class.java) - data?.apply { - val existingPaths = mutableListOf() - for (path in paths) { - if (File(path).exists()) { - existingPaths.add(path) - } - } - - if(!existingPaths.contains(selected)){ - selected = "" - } - pathsState?.clear() - pathsState?.addAll(existingPaths) - paths = existingPaths - } + refreshPaths() } storageHelper = MainActivity.StorageHelper!! diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt index 8f22d9e24..f567410f1 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt @@ -1,8 +1,9 @@ package org.ryujinx.android.views import android.content.res.Resources -import android.view.Gravity +import android.graphics.BitmapFactory import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth @@ -20,52 +22,51 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Card -import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogWindowProvider -import androidx.compose.ui.zIndex import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.anggrayudi.storage.extension.launchOnUiThread import org.ryujinx.android.MainActivity -import org.ryujinx.android.R +import org.ryujinx.android.NativeHelpers +import org.ryujinx.android.RyujinxNative import org.ryujinx.android.viewmodels.GameModel import org.ryujinx.android.viewmodels.HomeViewModel import java.io.File +import java.util.Base64 import java.util.Locale import kotlin.concurrent.thread import kotlin.math.roundToInt @@ -76,289 +77,274 @@ class HomeViews { @OptIn(ExperimentalMaterial3Api::class) @Composable - fun MainTopBar( - navController: NavHostController, - query: MutableState, - refresh: MutableState + fun Home( + viewModel: HomeViewModel = HomeViewModel(), + navController: NavHostController? = null ) { - val topBarSize = remember { - mutableStateOf(0) - } - Column { - val showOptionsPopup = remember { - mutableStateOf(false) - } - TopAppBar( - modifier = Modifier - .zIndex(1f) - .padding(top = 8.dp) - .onSizeChanged { - topBarSize.value = it.height - }, - title = { - DockedSearchBar( - shape = SearchBarDefaults.inputFieldShape, - query = query.value, - onQueryChange = { - query.value = it - }, - onSearch = {}, - active = false, - onActiveChange = {}, - leadingIcon = { - Icon( - Icons.Filled.Search, - contentDescription = "Search Games" - ) - }, - placeholder = { - Text(text = "Search Games") - } - ) { - - } - }, - actions = { - IconButton( - onClick = { - refresh.value = true - } - ) { - Icon( - Icons.Filled.Refresh, - contentDescription = "Refresh" - ) - } - IconButton( - onClick = { - showOptionsPopup.value = true - } - ) { - Icon( - Icons.Filled.MoreVert, - contentDescription = "More" - ) - } - } - ) - Box { - if (showOptionsPopup.value) { - AlertDialog( - modifier = Modifier.padding( - top = (topBarSize.value / Resources.getSystem().displayMetrics.density + 10).dp, - start = 16.dp, end = 16.dp - ), - onDismissRequest = { - showOptionsPopup.value = false - }) { - val dialogWindowProvider = - LocalView.current.parent as DialogWindowProvider - dialogWindowProvider.window.setGravity(Gravity.TOP) - Surface( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(16.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation - ) { - Column { - TextButton( - onClick = { - navController.navigate("settings") - }, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Start), - ) { - Icon( - Icons.Filled.Settings, - contentDescription = "Settings" - ) - Text( - text = "Settings", modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterVertically) - ) - } - } - } - } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun Home(viewModel: HomeViewModel = HomeViewModel(), navController: NavHostController? = null) { - val sheetState = rememberModalBottomSheetState() - val showBottomSheet = remember { mutableStateOf(false) } + val native = RyujinxNative() + val showAppActions = remember { mutableStateOf(false) } val showLoading = remember { mutableStateOf(false) } + val openTitleUpdateDialog = remember { mutableStateOf(false) } + val canClose = remember { mutableStateOf(true) } + val openDlcDialog = remember { mutableStateOf(false) } val query = remember { mutableStateOf("") } val refresh = remember { mutableStateOf(true) } + val refreshUser = remember { + mutableStateOf(true) + } + + viewModel.mainViewModel?.setRefreshUserState(refreshUser) + val user = remember { + mutableStateOf("") + } + val pic = remember { + mutableStateOf(ByteArray(0)) + } + + if (refreshUser.value) { + native.userGetOpenedUser() + user.value = NativeHelpers().popStringJava() + if (user.value.isNotEmpty()) { + val decoder = Base64.getDecoder() + pic.value = decoder.decode(native.userGetUserPicture(user.value)) + } + + refreshUser.value = false; + } Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - navController?.apply { - MainTopBar(navController, query, refresh) - } + TopAppBar( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + title = { + SearchBar( + modifier = Modifier.fillMaxWidth(), + shape = SearchBarDefaults.inputFieldShape, + query = query.value, + onQueryChange = { + query.value = it + }, + onSearch = {}, + active = false, + onActiveChange = {}, + leadingIcon = { + Icon( + Icons.Filled.Search, + contentDescription = "Search Games" + ) + }, + placeholder = { + Text(text = "Ryujinx") + } + ) { } + }, + actions = { + IconButton(onClick = { + navController?.navigate("user") + }) { + if (pic.value.isNotEmpty()) { + Image( + bitmap = BitmapFactory.decodeByteArray( + pic.value, + 0, + pic.value.size + ) + .asImageBitmap(), + contentDescription = "user image", + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(4.dp) + .size(52.dp) + .clip(CircleShape) + ) + } else { + Icon( + Icons.Filled.Person, + contentDescription = "user" + ) + } + } + IconButton( + onClick = { + navController?.navigate("settings") + } + ) { + Icon( + Icons.Filled.Settings, + contentDescription = "Settings" + ) + } + } + ) }, - floatingActionButtonPosition = FabPosition.End, - floatingActionButton = { - FloatingActionButton(onClick = { - viewModel.openGameFolder() + bottomBar = { + BottomAppBar(actions = { + if (showAppActions.value) { + IconButton(onClick = { + }) { + Icon( + org.ryujinx.android.Icons.playArrow(MaterialTheme.colorScheme.onSurface), + contentDescription = "Run" + ) + } + val showAppMenu = remember { mutableStateOf(false) } + Box { + IconButton(onClick = { + showAppMenu.value = true + }) { + Icon( + Icons.Filled.Menu, + contentDescription = "Menu" + ) + } + DropdownMenu( + expanded = showAppMenu.value, + onDismissRequest = { showAppMenu.value = false }) { + DropdownMenuItem(text = { + Text(text = "Clear PPTC Cache") + }, onClick = { + showAppMenu.value = false + viewModel.mainViewModel?.clearPptcCache(viewModel.mainViewModel?.selected?.titleId ?: "") + }) + DropdownMenuItem(text = { + Text(text = "Purge Shader Cache") + }, onClick = { + showAppMenu.value = false + viewModel.mainViewModel?.purgeShaderCache(viewModel.mainViewModel?.selected?.titleId ?: "") + }) + DropdownMenuItem(text = { + Text(text = "Manage Updates") + }, onClick = { + showAppMenu.value = false + openTitleUpdateDialog.value = true + }) + DropdownMenuItem(text = { + Text(text = "Manage DLC") + }, onClick = { + showAppMenu.value = false + openDlcDialog.value = true + }) + } + } + } }, - shape = CircleShape) { - Icon( - Icons.Filled.Add, - contentDescription = "Options" - ) - } + floatingActionButton = { + FloatingActionButton( + onClick = { + viewModel.openGameFolder() + }, + containerColor = BottomAppBarDefaults.bottomAppBarFabColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() + ) { + Icon( + org.ryujinx.android.Icons.folderOpen(MaterialTheme.colorScheme.onSurface), + contentDescription = "Open Folder" + ) + } + } + ) } + ) { contentPadding -> Box(modifier = Modifier.padding(contentPadding)) { val list = remember { mutableStateListOf() } - - - if(refresh.value) { + if (refresh.value) { viewModel.setViewList(list) refresh.value = false + showAppActions.value = false + } + val selectedModel = remember { + mutableStateOf(viewModel.mainViewModel?.selected) } LazyColumn(Modifier.fillMaxSize()) { items(list) { it.titleName?.apply { - if (this.isNotEmpty() && (query.value.trim().isEmpty() || this.lowercase( + if (this.isNotEmpty() && (query.value.trim() + .isEmpty() || this.lowercase( Locale.getDefault() ) - .contains(query.value))) - GameItem(it, viewModel, showBottomSheet, showLoading) + .contains(query.value)) + ) + GameItem( + it, + viewModel, + showAppActions, + showLoading, + selectedModel + ) } } } } - if(showLoading.value){ - AlertDialog(onDismissRequest = { }) { - Card(modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - shape = MaterialTheme.shapes.medium) { - Column(modifier = Modifier + if (showLoading.value) { + AlertDialog(onDismissRequest = { }) { + Card( + modifier = Modifier .padding(16.dp) - .fillMaxWidth()) { - Text(text = "Loading") - LinearProgressIndicator(modifier = Modifier + .fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .padding(16.dp) .fillMaxWidth() - .padding(top = 16.dp)) + ) { + Text(text = "Loading") + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) } } } } - - if(showBottomSheet.value) { - ModalBottomSheet(onDismissRequest = { - showBottomSheet.value = false - }, - sheetState = sheetState) { - val openTitleUpdateDialog = remember { mutableStateOf(false) } - val openDlcDialog = remember { mutableStateOf(false) } - if(openTitleUpdateDialog.value) { - AlertDialog(onDismissRequest = { - openTitleUpdateDialog.value = false - }) { - Surface( - modifier = Modifier - .wrapContentWidth() - .wrapContentHeight(), - shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation - ) { - val titleId = viewModel.mainViewModel?.selected?.titleId ?: "" - val name = viewModel.mainViewModel?.selected?.titleName ?: "" - TitleUpdateViews.Main(titleId, name, openTitleUpdateDialog) - } - - } + if (openTitleUpdateDialog.value) { + AlertDialog(onDismissRequest = { + openTitleUpdateDialog.value = false + }) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + val titleId = viewModel.mainViewModel?.selected?.titleId ?: "" + val name = viewModel.mainViewModel?.selected?.titleName ?: "" + TitleUpdateViews.Main(titleId, name, openTitleUpdateDialog, canClose) } - if(openDlcDialog.value) { - AlertDialog(onDismissRequest = { - openDlcDialog.value = false - }) { - Surface( - modifier = Modifier - .wrapContentWidth() - .wrapContentHeight(), - shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation - ) { - val titleId = viewModel.mainViewModel?.selected?.titleId ?: "" - val name = viewModel.mainViewModel?.selected?.titleName ?: "" - DlcViews.Main(titleId, name, openDlcDialog) - } - } } - Surface(color = MaterialTheme.colorScheme.surface, - modifier = Modifier.padding(16.dp)) { - Column(modifier = Modifier.fillMaxSize()) { - Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - Card( - modifier = Modifier.padding(8.dp), - onClick = { - openTitleUpdateDialog.value = true - } - ) { - Column(modifier = Modifier.padding(16.dp)) { - Icon( - painter = painterResource(R.drawable.app_update), - contentDescription = "Game Updates", - tint = Color.Green, - modifier = Modifier - .width(48.dp) - .height(48.dp) - .align(Alignment.CenterHorizontally) - ) - Text(text = "Game Updates", - modifier = Modifier.align(Alignment.CenterHorizontally), - color = MaterialTheme.colorScheme.onSurface) - - } - } - Card( - modifier = Modifier.padding(8.dp), - onClick = { - openDlcDialog.value = true - } - ) { - Column(modifier = Modifier.padding(16.dp)) { - Icon( - imageVector = org.ryujinx.android.Icons.download(), - contentDescription = "Game Dlc", - tint = Color.Green, - modifier = Modifier - .width(48.dp) - .height(48.dp) - .align(Alignment.CenterHorizontally) - ) - Text(text = "Game DLC", - modifier = Modifier.align(Alignment.CenterHorizontally), - color = MaterialTheme.colorScheme.onSurface) - - } - } - } - } + } + if (openDlcDialog.value) { + AlertDialog(onDismissRequest = { + openDlcDialog.value = false + }) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + val titleId = viewModel.mainViewModel?.selected?.titleId ?: "" + val name = viewModel.mainViewModel?.selected?.titleName ?: "" + DlcViews.Main(titleId, name, openDlcDialog) } + } } } @@ -369,16 +355,31 @@ class HomeViews { fun GameItem( gameModel: GameModel, viewModel: HomeViewModel, - showSheet: MutableState, - showLoading: MutableState + showAppActions: MutableState, + showLoading: MutableState, + selectedModel: MutableState ) { - Surface(shape = MaterialTheme.shapes.medium, + remember { + selectedModel + } + val color = + if (selectedModel.value == gameModel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface + + Surface( + shape = MaterialTheme.shapes.medium, + color = color, modifier = Modifier .fillMaxWidth() .padding(8.dp) .combinedClickable( onClick = { - if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000") { + if (viewModel.mainViewModel?.selected != null) { + showAppActions.value = false + viewModel.mainViewModel?.apply { + selected = null + } + selectedModel.value = null + } else if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000") { thread { showLoading.value = true val success = @@ -396,35 +397,40 @@ class HomeViews { }, onLongClick = { viewModel.mainViewModel?.selected = gameModel - showSheet.value = true - })) { - Row(modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween) { + showAppActions.value = true + selectedModel.value = gameModel + }) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { Row { - if(!gameModel.titleId.isNullOrEmpty() && gameModel.titleId != "0000000000000000") - { - val iconSource = MainActivity.AppPath + "/iconCache/" + gameModel.iconCache + if (!gameModel.titleId.isNullOrEmpty() && gameModel.titleId != "0000000000000000") { + val iconSource = + MainActivity.AppPath + "/iconCache/" + gameModel.iconCache val imageFile = File(iconSource) - if(imageFile.exists()) { + if (imageFile.exists()) { val size = ImageSize / Resources.getSystem().displayMetrics.density - AsyncImage(model = imageFile, + AsyncImage( + model = imageFile, contentDescription = gameModel.titleName + " icon", - modifier = Modifier - .padding(end = 8.dp) - .width(size.roundToInt().dp) - .height(size.roundToInt().dp)) - } - else NotAvailableIcon() + modifier = Modifier + .padding(end = 8.dp) + .width(size.roundToInt().dp) + .height(size.roundToInt().dp) + ) + } else NotAvailableIcon() } else NotAvailableIcon() - Column{ + Column { Text(text = gameModel.titleName ?: "") Text(text = gameModel.developer ?: "") Text(text = gameModel.titleId ?: "") } } - Column{ + Column { Text(text = gameModel.version ?: "") Text(text = String.format("%.3f", gameModel.fileSize)) } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/MainView.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/MainView.kt index 7c5c988e1..b613cc51d 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/MainView.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/MainView.kt @@ -16,12 +16,13 @@ class MainView { NavHost(navController = navController, startDestination = "home") { composable("home") { HomeViews.Home(mainViewModel.homeViewModel, navController) } + composable("user") { UserViews.Main(mainViewModel, navController) } composable("settings") { SettingViews.Main( SettingsViewModel( navController, mainViewModel.activity - ) + ), mainViewModel ) } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt index f7cc010c0..d1d00251c 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt @@ -3,9 +3,7 @@ package org.ryujinx.android.views import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition @@ -23,6 +21,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -33,6 +33,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold @@ -51,16 +52,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.extension +import org.ryujinx.android.Helpers +import org.ryujinx.android.MainActivity +import org.ryujinx.android.viewmodels.MainViewModel import org.ryujinx.android.viewmodels.SettingsViewModel import org.ryujinx.android.viewmodels.VulkanDriverViewModel +import kotlin.concurrent.thread class SettingViews { companion object { const val EXPANSTION_TRANSITION_DURATION = 450 + const val IMPORT_CODE = 12341 @OptIn(ExperimentalMaterial3Api::class) @Composable - fun Main(settingsViewModel: SettingsViewModel) { + fun Main(settingsViewModel: SettingsViewModel, mainViewModel: MainViewModel) { val loaded = remember { mutableStateOf(false) } @@ -134,7 +142,9 @@ class SettingViews { } }) }) { contentPadding -> - Column(modifier = Modifier.padding(contentPadding)) { + Column(modifier = Modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState())) { ExpandableView(onCardArrowClick = { }, title = "System") { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -227,6 +237,121 @@ class SettingViews { ignoreMissingServices.value = !ignoreMissingServices.value }) } + val isImporting = remember { + mutableStateOf(false) + } + val showImportWarning = remember { + mutableStateOf(false) + } + val showImportCompletion = remember { + mutableStateOf(false) + } + var importFile = remember { + mutableStateOf(null) + } + Button(onClick = { + val storage = MainActivity.StorageHelper + storage?.apply { + val s = this.storage + val callBack = this.onFileSelected + onFileSelected = { requestCode, files -> + run { + onFileSelected = callBack + if (requestCode == IMPORT_CODE) { + val file = files.firstOrNull() + file?.apply { + if (this.extension == "zip") { + importFile.value = this + showImportWarning.value = true + } + } + } + } + } + openFilePicker( + IMPORT_CODE, + filterMimeTypes = arrayOf("application/zip") + ) + } + }) { + Text(text = "Import App Data") + } + + if (showImportWarning.value) { + AlertDialog(onDismissRequest = { + showImportWarning.value = false + importFile.value = null + }) { + Card( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Text(text = "Importing app data will delete your current profile. Do you still want to continue?") + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + Button(onClick = { + val file = importFile.value + showImportWarning.value = false + importFile.value = null + file?.apply { + thread { + Helpers.importAppData(this, isImporting) + showImportCompletion.value = true + mainViewModel.requestUserRefresh() + } + } + }, modifier = Modifier.padding(horizontal = 8.dp)) { + Text(text = "Yes") + } + Button(onClick = { + showImportWarning.value = false + importFile.value = null + }, modifier = Modifier.padding(horizontal = 8.dp)) { + Text(text = "No") + } + } + } + + } + } + } + + if (showImportCompletion.value) { + AlertDialog(onDismissRequest = { + showImportCompletion.value = false + importFile.value = null + mainViewModel.requestUserRefresh() + mainViewModel.homeViewModel.clearLoadedCache() + }) { + Card( + modifier = Modifier, + shape = MaterialTheme.shapes.medium + ) { + Text(modifier = Modifier + .padding(24.dp), + text = "App Data import completed.") + } + } + } + + if (isImporting.value) { + Text(text = "Importing Files") + + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + } } } ExpandableView(onCardArrowClick = { }, title = "Graphics") { @@ -257,14 +382,14 @@ class SettingViews { text = "Resolution Scale", modifier = Modifier.align(Alignment.CenterVertically) ) - Text(text = resScale.value.toString() +"x") + Text(text = resScale.value.toString() + "x") } Slider(value = resScale.value, valueRange = 0.5f..4f, steps = 6, onValueChange = { it -> resScale.value = it - } ) + }) Row( modifier = Modifier .fillMaxWidth() @@ -276,9 +401,12 @@ class SettingViews { text = "Enable Texture Recompression", modifier = Modifier.align(Alignment.CenterVertically) ) - Switch(checked = enableTextureRecompression.value, onCheckedChange = { - enableTextureRecompression.value = !enableTextureRecompression.value - }) + Switch( + checked = enableTextureRecompression.value, + onCheckedChange = { + enableTextureRecompression.value = + !enableTextureRecompression.value + }) } Row( modifier = Modifier @@ -290,7 +418,8 @@ class SettingViews { var isDriverSelectorOpen = remember { mutableStateOf(false) } - var driverViewModel = VulkanDriverViewModel(settingsViewModel.activity) + var driverViewModel = + VulkanDriverViewModel(settingsViewModel.activity) var isChanged = remember { mutableStateOf(false) } @@ -302,16 +431,16 @@ class SettingViews { mutableStateOf(0) } - if(refresh.value) { + if (refresh.value) { isChanged.value = true refresh.value = false } - if(isDriverSelectorOpen.value){ + if (isDriverSelectorOpen.value) { AlertDialog(onDismissRequest = { isDriverSelectorOpen.value = false - if(isChanged.value){ + if (isChanged.value) { driverViewModel.saveSelected() } }) { @@ -329,11 +458,15 @@ class SettingViews { isChanged.value = true } Column { - Column (modifier = Modifier - .fillMaxWidth() - .height(300.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) { Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton( @@ -359,7 +492,9 @@ class SettingViews { for (driver in drivers) { var ind = driverIndex Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton( @@ -378,15 +513,21 @@ class SettingViews { driverViewModel.selected = driver.driverPath }) { - Text(text = driver.libraryName, + Text( + text = driver.libraryName, modifier = Modifier - .fillMaxWidth()) - Text(text = driver.driverVersion, + .fillMaxWidth() + ) + Text( + text = driver.driverVersion, modifier = Modifier - .fillMaxWidth()) - Text(text = driver.description, + .fillMaxWidth() + ) + Text( + text = driver.description, modifier = Modifier - .fillMaxWidth()) + .fillMaxWidth() + ) } } @@ -425,7 +566,7 @@ class SettingViews { isDriverSelectorOpen.value = !isDriverSelectorOpen.value }, modifier = Modifier.align(Alignment.CenterVertically) - ){ + ) { Text(text = "Drivers") } } @@ -485,24 +626,6 @@ class SettingViews { } } val transition = updateTransition(transitionState, label = "transition") - val cardPaddingHorizontal by transition.animateDp({ - tween(durationMillis = EXPANSTION_TRANSITION_DURATION) - }, label = "paddingTransition") { - if (mutableExpanded.value) 48.dp else 24.dp - } - val cardElevation by transition.animateDp({ - tween(durationMillis = EXPANSTION_TRANSITION_DURATION) - }, label = "elevationTransition") { - if (mutableExpanded.value) 24.dp else 4.dp - } - val cardRoundedCorners by transition.animateDp({ - tween( - durationMillis = EXPANSTION_TRANSITION_DURATION, - easing = FastOutSlowInEasing - ) - }, label = "cornersTransition") { - if (mutableExpanded.value) 0.dp else 16.dp - } val arrowRotationDegree by transition.animateFloat({ tween(durationMillis = EXPANSTION_TRANSITION_DURATION) }, label = "rotationDegreeTransition") { @@ -514,7 +637,7 @@ class SettingViews { modifier = Modifier .fillMaxWidth() .padding( - horizontal = cardPaddingHorizontal, + horizontal = 24.dp, vertical = 8.dp ) ) { @@ -600,4 +723,4 @@ class SettingViews { } } } -} \ No newline at end of file +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt index 558bc559b..96347f259 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt @@ -27,11 +27,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.ryujinx.android.viewmodels.TitleUpdateViewModel +import java.io.File class TitleUpdateViews { companion object { @Composable - fun Main(titleId: String, name: String, openDialog: MutableState) { + fun Main(titleId: String, name: String, openDialog: MutableState, canClose: MutableState) { val viewModel = TitleUpdateViewModel(titleId) val selected = remember { mutableStateOf(0) } @@ -46,6 +47,9 @@ class TitleUpdateViews { val copyProgress = remember { mutableStateOf(0.0f) } + var currentProgressName = remember { + mutableStateOf("Starting Copy") + } Column { Text(text = "Updates for ${name}", textAlign = TextAlign.Center) Surface( @@ -77,7 +81,7 @@ class TitleUpdateViews { mutableStateListOf() } - viewModel.setPaths(paths) + viewModel.setPaths(paths, canClose) var index = 1 for (path in paths) { val i = index @@ -86,7 +90,7 @@ class TitleUpdateViews { selected = (selected.value == i), onClick = { selected.value = i }) Text( - text = path, + text = File(path).name, modifier = Modifier .fillMaxWidth() .align(Alignment.CenterVertically) @@ -111,7 +115,7 @@ class TitleUpdateViews { IconButton( onClick = { - viewModel.Add() + viewModel.Add(isCopying, copyProgress, currentProgressName) } ) { Icon( @@ -122,22 +126,33 @@ class TitleUpdateViews { } } - var currentProgressName = remember { - mutableStateOf("Starting Copy") - } if (isCopying.value) { Text(text = "Copying updates to local storage") Text(text = currentProgressName.value) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - progress = copyProgress.value - ) + Row { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = copyProgress.value + ) + TextButton( + onClick = { + isCopying.value = false + canClose.value = true + viewModel.refreshPaths() + }, + ) { + Text("Cancel") + } + } } Spacer(modifier = Modifier.height(18.dp)) TextButton( modifier = Modifier.align(Alignment.End), onClick = { - viewModel.save(selected.value, isCopying, openDialog, copyProgress, currentProgressName) + if (!isCopying.value) { + canClose.value = true + viewModel.save(selected.value, openDialog) + } }, ) { Text("Save") diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/UserViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/UserViews.kt new file mode 100644 index 000000000..63f0f2379 --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/UserViews.kt @@ -0,0 +1,185 @@ +package org.ryujinx.android.views + +import android.graphics.BitmapFactory +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import org.ryujinx.android.NativeHelpers +import org.ryujinx.android.RyujinxNative +import org.ryujinx.android.viewmodels.MainViewModel +import java.util.Base64 + +class UserViews { + companion object { + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) + @Composable + fun Main(viewModel: MainViewModel? = null, navController: NavHostController? = null) { + val ryujinxNative = RyujinxNative() + val decoder = Base64.getDecoder() + ryujinxNative.userGetOpenedUser() + val openedUser = remember { + mutableStateOf(NativeHelpers().popStringJava()) + } + + val openedUserPic = remember { + mutableStateOf(decoder.decode(ryujinxNative.userGetUserPicture(openedUser.value))) + } + val openedUserName = remember { + mutableStateOf(ryujinxNative.userGetUserName(openedUser.value)) + } + + val userList = remember { + mutableListOf("") + } + + fun refresh() { + userList.clear() + userList.addAll(ryujinxNative.userGetAllUsers()) + } + + refresh() + + Scaffold(modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar(title = { + Text(text = "Users") + }, + navigationIcon = { + IconButton(onClick = { + viewModel?.navController?.popBackStack() + }) { + Icon(Icons.Filled.ArrowBack, contentDescription = "Back") + } + }) + }) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = "Selected user") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + bitmap = BitmapFactory.decodeByteArray( + openedUserPic.value, + 0, + openedUserPic.value.size + ).asImageBitmap(), + contentDescription = "selected image", + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(4.dp) + .size(96.dp) + .clip(CircleShape) + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = openedUserName.value) + Text(text = openedUser.value) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Available Users") + IconButton(onClick = { + refresh() + }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "refresh users" + ) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + ) { + items(userList) { user -> + val pic = decoder.decode(ryujinxNative.userGetUserPicture(user)) + val name = ryujinxNative.userGetUserName(user) + Image( + bitmap = BitmapFactory.decodeByteArray(pic, 0, pic.size) + .asImageBitmap(), + contentDescription = "selected image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .clip(CircleShape) + .align(Alignment.CenterHorizontally) + .combinedClickable( + onClick = { + ryujinxNative.userOpenUser(user) + openedUser.value = user + openedUserPic.value = pic + openedUserName.value = name + viewModel?.requestUserRefresh() + }) + ) + } + } + } + + } + } + } + + } + + @Preview + @Composable + fun Preview() { + UserViews.Main() + } +} diff --git a/src/RyujinxAndroid/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/RyujinxAndroid/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/src/RyujinxAndroid/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_background.xml b/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9cb..000000000 --- a/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_foreground.xml b/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..c4dd11353 --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755bf..7353dbd1f 100644 --- a/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755bf..7353dbd1f 100644 --- a/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/src/RyujinxAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ec..000000000 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d1b..3a8b2257a 100644 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/src/RyujinxAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e..000000000 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611da0..b863faa25 100644 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/src/RyujinxAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070f..000000000 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a6956b..94f9c7208 100644 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/src/RyujinxAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f9..000000000 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f5083..c0977e6a3 100644 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/src/RyujinxAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427e..000000000 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae37c..1bd79c407 100644 Binary files a/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/src/RyujinxAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/src/RyujinxAndroid/app/src/main/res/values/ic_launcher_background.xml b/src/RyujinxAndroid/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file