├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ └── build.yml ├── .gitignore ├── .gitmodules ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── render.experimental.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── rpcs3 │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── cpp │ │ ├── CMakeLists.txt │ │ ├── block_dev.hpp │ │ ├── compile_commands.json │ │ ├── fs_provider.hpp │ │ ├── iso.cpp │ │ ├── iso.hpp │ │ └── native-lib.cpp │ ├── ic_rpcs3-playstore.png │ ├── java │ │ └── net │ │ │ └── rpcs3 │ │ │ ├── FirmwareRepository.kt │ │ │ ├── GameRepository.kt │ │ │ ├── GraphicsFrame.kt │ │ │ ├── MainActivity.kt │ │ │ ├── Permission.kt │ │ │ ├── PrecompilerService.kt │ │ │ ├── ProgressRepository.kt │ │ │ ├── RPCS3.kt │ │ │ ├── RPCS3Activity.kt │ │ │ ├── RPCS3Theme.kt │ │ │ ├── UsbDeviceRepository.kt │ │ │ ├── dialogs │ │ │ └── AlertDialogQueue.kt │ │ │ ├── overlay │ │ │ ├── OverlayEditActivity.kt │ │ │ ├── PadOverlay.kt │ │ │ ├── PadOverlayButton.kt │ │ │ ├── PadOverlayDpad.kt │ │ │ └── PadOverlayStick.kt │ │ │ ├── provider │ │ │ └── AppDataDocumentProvider.kt │ │ │ ├── ui │ │ │ ├── common │ │ │ │ └── Previews.kt │ │ │ ├── drivers │ │ │ │ └── GpuDriversScreen.kt │ │ │ ├── games │ │ │ │ └── GamesScreen.kt │ │ │ ├── navigation │ │ │ │ └── AppNavHost.kt │ │ │ └── settings │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── components │ │ │ │ ├── CompositionLocals.kt │ │ │ │ ├── base │ │ │ │ │ ├── BaseDialogPreference.kt │ │ │ │ │ └── BasePreference.kt │ │ │ │ ├── core │ │ │ │ │ ├── MaterialSwitch.kt │ │ │ │ │ ├── PreferenceIcon.kt │ │ │ │ │ └── PreferenceTitle.kt │ │ │ │ └── preference │ │ │ │ │ ├── CheckboxPreference.kt │ │ │ │ │ ├── HomePreference.kt │ │ │ │ │ ├── ListPreference.kt │ │ │ │ │ ├── RegularPreference.kt │ │ │ │ │ ├── SliderPreference.kt │ │ │ │ │ └── SwitchPreference.kt │ │ │ │ └── util │ │ │ │ ├── ModifierExt.kt │ │ │ │ └── Theming.kt │ │ │ └── utils │ │ │ ├── DriverPackageMetadata.kt │ │ │ ├── DriversFetcher.kt │ │ │ ├── FileUtil.kt │ │ │ ├── GpuDriverHelper.kt │ │ │ └── ZipUtil.kt │ └── res │ │ ├── drawable │ │ ├── circle.png │ │ ├── cross.png │ │ ├── dpad_bottom.png │ │ ├── dpad_left.png │ │ ├── dpad_right.png │ │ ├── dpad_top.png │ │ ├── ic_description.xml │ │ ├── ic_folder.xml │ │ ├── ic_grid_off.xml │ │ ├── ic_grid_on.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_osc_off.xml │ │ ├── ic_palette.xml │ │ ├── ic_play.xml │ │ ├── ic_restore.xml │ │ ├── ic_rpcs3_foreground.xml │ │ ├── ic_rpcs3_monochrome.xml │ │ ├── ic_show_osc.xml │ │ ├── ic_stop.xml │ │ ├── l1.png │ │ ├── l2.png │ │ ├── l3.png │ │ ├── left_stick.png │ │ ├── left_stick_background.png │ │ ├── r1.png │ │ ├── r2.png │ │ ├── r3.png │ │ ├── right_stick.png │ │ ├── right_stick_background.png │ │ ├── rounded_background.xml │ │ ├── select.png │ │ ├── square.png │ │ ├── start.png │ │ └── triangle.png │ │ ├── layout │ │ ├── activity_main.xml │ │ └── activity_rpcs3.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_rpcs3.xml │ │ └── ic_rpcs3_round.xml │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_round.webp │ │ ├── ic_rpcs3.webp │ │ └── ic_rpcs3_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_round.webp │ │ ├── ic_rpcs3.webp │ │ └── ic_rpcs3_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_round.webp │ │ ├── ic_rpcs3.webp │ │ └── ic_rpcs3_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_round.webp │ │ ├── ic_rpcs3.webp │ │ └── ic_rpcs3_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_round.webp │ │ ├── ic_rpcs3.webp │ │ └── ic_rpcs3_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_rpcs3_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── game_config.xml │ └── test │ └── java │ └── net │ └── rpcs3 │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve and investigate the issue 3 | title: "[Bug] " 4 | labels: bug 5 | body: 6 | - type: input 7 | id: app-title 8 | attributes: 9 | label: Describe the bug 10 | description: A clear and concise description of what the bug is. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: stepstoreproducebug 16 | attributes: 17 | label: Steps to reproduce the behavior 18 | value: | 19 | - 1. Go to '...' 20 | - 2. Click on '....' 21 | - 3. Scroll down to '....' 22 | - 4. See error 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: upload-screenshots 28 | attributes: 29 | label: Screenshots 30 | description: If applicable, add screenshots to help explain your problem. 31 | placeholder: Upload your screenshots here 32 | validations: 33 | required: false 34 | 35 | - type: textarea 36 | id: phonespecs 37 | attributes: 38 | label: Smartphone (please complete the following information) 39 | value: | 40 | - Device: [e.g. Pixel 32 Pro] 41 | - OS: [e.g. OneUI 21] 42 | - Version [e.g. v2.1.0] 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | id: extracontext 48 | attributes: 49 | label: Additional context 50 | description: Add any other context about the problem here. 51 | validations: 52 | required: false 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: RPCS3-Android Discord Server 4 | url: https://discord.gg/3PprUAVjfr 5 | about: The Official RPCS3-Android Discord Server 6 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Todo List: 2 | # - Move librpcs3 compilation away (or add ccache here) because the compilation is ridiculously slow 3 | # - Do not use legacy CMake scripts (figure out how) 4 | 5 | name: Java CI with Gradle 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | shouldrelease: 11 | description: Create release 12 | type: boolean 13 | uploadbuild: 14 | description: Upload build 15 | type: boolean 16 | releasetag: 17 | description: Enter the tag name 18 | type: string 19 | releasetype: 20 | description: Make it prerelease 21 | type: boolean 22 | islatest: 23 | description: Mark as latest 24 | type: boolean 25 | push: 26 | branches: [ "master" ] 27 | pull_request: 28 | branches: [ "master" ] 29 | 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: write 35 | 36 | steps: 37 | - name: Checkout repo 38 | uses: actions/checkout@v4 39 | with: 40 | submodules: recursive 41 | 42 | - name: Setup CCache 43 | uses: hendrikmuhs/ccache-action@v1.2 44 | 45 | - name: Setup Gradle Cache 46 | uses: actions/cache@v4 47 | with: 48 | path: | 49 | ~/.gradle/caches 50 | ~/.gradle/wrapper 51 | key: ${{ runner.os }}-android-${{ github.sha }} 52 | restore-keys: | 53 | ${{ runner.os }}-android- 54 | 55 | - name: Setup Java 56 | uses: actions/setup-java@v4 57 | with: 58 | distribution: 'temurin' 59 | java-version: 17 60 | 61 | - name: Decode Keystore 62 | env: 63 | KEYSTORE_ENCODED: ${{ secrets.KEYSTORE }} 64 | run: | 65 | echo "$KEYSTORE_ENCODED" | base64 --decode > ${{ github.workspace }}/app/ks.jks 66 | 67 | - name: Build with Gradle 68 | env: 69 | KEYSTORE_PATH: "ks.jks" 70 | KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} 71 | KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }} 72 | CMAKE_C_COMPILER_LAUNCHER: "ccache" 73 | CMAKE_CXX_COMPILER_LAUNCHER: "ccache" 74 | run: ./gradlew assembleRelease 75 | 76 | - name: Build release artifacts 77 | uses: actions/upload-artifact@v4 78 | if: ${{ github.event_name != 'workflow_dispatch' }} || ${{ inputs.uploadbuild }} 79 | with: 80 | name: rpcs3-build 81 | path: | 82 | app/build/outputs/apk/release/rpcs3-release.apk 83 | 84 | - name: Create release 85 | uses: softprops/action-gh-release@v2 86 | if: ${{ inputs.shouldrelease }} 87 | with: 88 | prerelease: ${{ inputs.releasetype }} 89 | tag_name: ${{ inputs.releasetag }} 90 | make_latest: ${{ inputs.islatest }} 91 | # body: 'Some release text body if needed' 92 | # body_path: or/as/file.md 93 | files: | 94 | app/build/outputs/apk/release/rpcs3-release.apk 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .cache 17 | **/build*/ 18 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "app/src/main/cpp/rpcs3"] 2 | path = app/src/main/cpp/rpcs3 3 | url = ../../DHrpcs3/rpcs3.git 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | RPCS3 -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/render.experimental.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # RPCS3-Android 4 | 5 | *An experimental [RPCS3](https://github.com/RPCS3/rpcs3) emulator port to Android* 6 | 7 | > **This project is discontinued. Alpha-7 is last release** 8 | > Fork of RPCS3 is merged with [RPCSX](https://github.com/RPCSX/rpcsx) 9 | > RPCS3-Android is reuploaded as [RPCSX Android UI](https://github.com/RPCSX/rpcsx-ui-android) 10 | 11 |
12 | 13 | ## Requirements 14 | 15 | Android 12+ 16 | 17 | 18 | ## License 19 | 20 | RPCS3-Android is licensed under GPLv2 license except directories containing their own LICENSE file, or files containing their own license. 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | id("org.jetbrains.kotlin.plugin.serialization") 6 | id("kotlin-parcelize") 7 | } 8 | 9 | android { 10 | namespace = "net.rpcs3" 11 | compileSdk = 35 12 | 13 | defaultConfig { 14 | applicationId = "net.rpcs3" 15 | minSdk = 31 16 | targetSdk = 35 17 | versionCode = 1 18 | versionName = "1.0" 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | ndk { 22 | abiFilters += listOf("arm64-v8a" /*, "x86_64" */) 23 | } 24 | } 25 | 26 | signingConfigs { 27 | create("custom-key") { 28 | val keystoreAlias = System.getenv("KEYSTORE_ALIAS") ?: "" 29 | val keystorePassword = System.getenv("KEYSTORE_PASSWORD") ?: "" 30 | val keystorePath = System.getenv("KEYSTORE_PATH") ?: "" 31 | 32 | if (keystorePath.isNotEmpty() && file(keystorePath).exists() && file(keystorePath).length() > 0) { 33 | keyAlias = keystoreAlias 34 | keyPassword = keystorePassword 35 | storeFile = file(keystorePath) 36 | storePassword = keystorePassword 37 | } else { 38 | val debugKeystoreFile = file("${System.getProperty("user.home")}/debug.keystore") 39 | 40 | println("⚠️ Custom keystore not found or empty! creating debug keystore.") 41 | 42 | if (!debugKeystoreFile.exists()) { 43 | Runtime.getRuntime().exec( 44 | arrayOf( 45 | "keytool", "-genkeypair", 46 | "-v", "-keystore", debugKeystoreFile.absolutePath, 47 | "-storepass", "android", 48 | "-keypass", "android", 49 | "-alias", "androiddebugkey", 50 | "-keyalg", "RSA", 51 | "-keysize", "2048", 52 | "-validity", "10000", 53 | "-dname", "CN=Android Debug,O=Android,C=US" 54 | ) 55 | ).waitFor() 56 | } 57 | 58 | keyAlias = "androiddebugkey" 59 | keyPassword = "android" 60 | storeFile = debugKeystoreFile 61 | storePassword = "android" 62 | } 63 | } 64 | } 65 | 66 | buildTypes { 67 | release { 68 | isMinifyEnabled = true 69 | isShrinkResources = true 70 | proguardFiles( 71 | getDefaultProguardFile("proguard-android-optimize.txt"), 72 | "proguard-rules.pro" 73 | ) 74 | signingConfig = signingConfigs.getByName("custom-key") ?: signingConfigs.getByName("debug") 75 | } 76 | } 77 | 78 | compileOptions { 79 | sourceCompatibility = JavaVersion.VERSION_11 80 | targetCompatibility = JavaVersion.VERSION_11 81 | } 82 | 83 | kotlinOptions { 84 | jvmTarget = "11" 85 | } 86 | 87 | externalNativeBuild { 88 | cmake { 89 | path = file("src/main/cpp/CMakeLists.txt") 90 | version = "3.31.6" 91 | } 92 | } 93 | 94 | buildFeatures { 95 | viewBinding = true 96 | compose = true 97 | } 98 | 99 | composeOptions { 100 | kotlinCompilerExtensionVersion = "1.5.15" 101 | } 102 | 103 | packaging { 104 | // This is necessary for libadrenotools custom driver loading 105 | jniLibs.useLegacyPackaging = true 106 | } 107 | } 108 | 109 | base.archivesName = "rpcs3" 110 | 111 | dependencies { 112 | implementation(libs.androidx.navigation.compose) 113 | implementation(libs.androidx.ui.tooling.preview.android) 114 | val composeBom = platform("androidx.compose:compose-bom:2025.02.00") 115 | implementation(composeBom) 116 | implementation(libs.androidx.material3) 117 | implementation(libs.androidx.core.ktx) 118 | implementation(libs.androidx.activity.compose) 119 | implementation(libs.androidx.appcompat) 120 | implementation(libs.material) 121 | implementation(libs.androidx.constraintlayout) 122 | implementation(libs.androidx.activity) 123 | testImplementation(libs.junit) 124 | androidTestImplementation(libs.androidx.junit) 125 | androidTestImplementation(libs.androidx.espresso.core) 126 | debugImplementation(libs.androidx.ui.tooling) 127 | implementation(libs.kotlinx.serialization.json) 128 | implementation(libs.coil.compose) 129 | implementation("io.ktor:ktor-client-core:3.0.3") 130 | implementation("io.ktor:ktor-client-cio:3.0.3") 131 | implementation("io.ktor:ktor-client-json:3.0.3") 132 | implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3") 133 | implementation("io.ktor:ktor-client-content-negotiation:3.0.3") 134 | implementation("io.ktor:ktor-client-logging:3.0.3") 135 | } 136 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/net/rpcs3/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("net.rpcs3", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 29 | 30 | 34 | 35 | 36 | 39 | 40 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 58 | 59 | 60 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /app/src/main/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.16.9) 2 | project("rpcs3-android") 3 | 4 | set(CMAKE_CXX_STANDARD 20) 5 | set(CMAKE_POSITION_INDEPENDENT_CODE on) 6 | 7 | set(FFMPEG_VERSION 5.1) 8 | set(LLVM_VERSION 19.1) 9 | 10 | if (TEST_OVERRIDE_CPU) 11 | if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") 12 | set(ARCH_FLAGS "-mcpu=cortex-a53") 13 | else() 14 | set(ARCH_FLAGS "-mno-avx") 15 | endif() 16 | 17 | 18 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${ARCH_FLAGS}") 19 | set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${ARCH_FLAGS}") 20 | set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${ARCH_FLAGS}") 21 | set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${ARCH_FLAGS}") 22 | endif() 23 | 24 | if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") 25 | set(RPCS3_DOWNLOAD_ARCH "arm64-v8a") 26 | else() 27 | set(RPCS3_DOWNLOAD_ARCH "x86-64") 28 | endif() 29 | 30 | if(NOT EXISTS ${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}.tar.gz) 31 | message(STATUS "Downloading ffmpeg-${FFMPEG_VERSION}") 32 | file(DOWNLOAD 33 | https://github.com/RPCS3-Android/ffmpeg-android/releases/download/${FFMPEG_VERSION}/ffmpeg-${RPCS3_DOWNLOAD_ARCH}-Android.tar.gz 34 | ${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}.tar.gz 35 | SHOW_PROGRESS 36 | ) 37 | endif() 38 | 39 | set(FFMPEG_PATH "${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}") 40 | 41 | make_directory(${FFMPEG_PATH}) 42 | add_custom_command( 43 | OUTPUT ${FFMPEG_PATH}/src 44 | COMMAND ${CMAKE_COMMAND} -E tar xzf ${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}.tar.gz 45 | DEPENDS ${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}.tar.gz 46 | WORKING_DIRECTORY ${FFMPEG_PATH} 47 | COMMENT "Unpacking ${CMAKE_BINARY_DIR}/ffmpeg-${FFMPEG_VERSION}.tar.gz" 48 | ) 49 | 50 | add_custom_target(ffmpeg-unpack DEPENDS ${FFMPEG_PATH}/src) 51 | 52 | function(import_ffmpeg_library name) 53 | add_custom_command( 54 | OUTPUT "${FFMPEG_PATH}/lib${name}/lib${name}.a" 55 | DEPENDS ffmpeg-unpack 56 | WORKING_DIRECTORY ${FFMPEG_PATH} 57 | ) 58 | 59 | add_custom_target(ffmpeg-unpack-${name} DEPENDS "${FFMPEG_PATH}/lib${name}/lib${name}.a") 60 | add_library(ffmpeg::${name} STATIC IMPORTED GLOBAL) 61 | set_property(TARGET ffmpeg::${name} PROPERTY IMPORTED_LOCATION "${FFMPEG_PATH}/lib${name}/lib${name}.a") 62 | set_property(TARGET ffmpeg::${name} PROPERTY INTERFACE_INCLUDE_DIRECTORIES "${FFMPEG_PATH}" "${FFMPEG_PATH}/src") 63 | add_dependencies(ffmpeg::${name} ffmpeg-unpack-${name}) 64 | endfunction() 65 | 66 | import_ffmpeg_library(avcodec) 67 | import_ffmpeg_library(avformat) 68 | import_ffmpeg_library(avfilter) 69 | import_ffmpeg_library(avdevice) 70 | import_ffmpeg_library(avutil) 71 | import_ffmpeg_library(swscale) 72 | import_ffmpeg_library(swresample) 73 | 74 | add_library(3rdparty_ffmpeg INTERFACE) 75 | target_link_libraries(3rdparty_ffmpeg INTERFACE 76 | ffmpeg::avformat 77 | ffmpeg::avcodec 78 | ffmpeg::avutil 79 | ffmpeg::swscale 80 | ffmpeg::swresample 81 | ) 82 | 83 | add_dependencies(3rdparty_ffmpeg ffmpeg-unpack) 84 | 85 | 86 | if(NOT EXISTS ${CMAKE_BINARY_DIR}/llvm-${LLVM_VERSION}.tar.gz) 87 | message(STATUS "Downloading llvm-${LLVM_VERSION}") 88 | file(DOWNLOAD 89 | https://github.com/RPCS3-Android/llvm-android/releases/download/${LLVM_VERSION}/llvm-${RPCS3_DOWNLOAD_ARCH}-Android.tar.gz 90 | ${CMAKE_BINARY_DIR}/llvm-${LLVM_VERSION}.tar.gz 91 | SHOW_PROGRESS 92 | ) 93 | endif() 94 | 95 | set(LLVM_DIR ${CMAKE_BINARY_DIR}/llvm-${LLVM_VERSION}.7-Android/lib/cmake/llvm) 96 | 97 | if (NOT EXISTS ${LLVM_DIR}) 98 | message(STATUS "Unpacking llvm-${LLVM_VERSION}") 99 | execute_process( 100 | COMMAND ${CMAKE_COMMAND} -E tar xzf ${CMAKE_BINARY_DIR}/llvm-${LLVM_VERSION}.tar.gz 101 | WORKING_DIRECTORY ${CMAKE_BINARY_DIR} 102 | ) 103 | endif() 104 | 105 | set(USE_SYSTEM_LIBUSB off) 106 | set(USE_SYSTEM_CURL off) 107 | set(USE_DISCORD_RPC off) 108 | set(USE_SYSTEM_OPENCV off) 109 | set(USE_SYSTEM_FFMPEG off) 110 | set(USE_FAUDIO off) 111 | set(USE_SDL2 off) 112 | set(BUILD_LLVM off) 113 | set(STATIC_LINK_LLVM on) 114 | set(DISABLE_LTO on) 115 | set(USE_LTO off) 116 | set(USE_OPENSL off) 117 | set(ASMJIT_NO_SHM_OPEN on) 118 | set(USE_SYSTEM_ZLIB on) 119 | set(USE_LIBEVDEV off) 120 | 121 | add_subdirectory(rpcs3 EXCLUDE_FROM_ALL) 122 | 123 | add_library(${CMAKE_PROJECT_NAME} SHARED 124 | native-lib.cpp 125 | iso.cpp 126 | rpcs3/rpcs3/stb_image.cpp 127 | rpcs3/rpcs3/Input/ds3_pad_handler.cpp 128 | rpcs3/rpcs3/Input/ds4_pad_handler.cpp 129 | rpcs3/rpcs3/Input/dualsense_pad_handler.cpp 130 | rpcs3/rpcs3/Input/evdev_joystick_handler.cpp 131 | rpcs3/rpcs3/Input/evdev_gun_handler.cpp 132 | # rpcs3/rpcs3/Input/gui_pad_thread.cpp 133 | rpcs3/rpcs3/Input/hid_pad_handler.cpp 134 | rpcs3/rpcs3/Input/virtual_pad_handler.cpp 135 | rpcs3/rpcs3/Input/mm_joystick_handler.cpp 136 | rpcs3/rpcs3/Input/pad_thread.cpp 137 | rpcs3/rpcs3/Input/product_info.cpp 138 | rpcs3/rpcs3/Input/ps_move_calibration.cpp 139 | rpcs3/rpcs3/Input/ps_move_config.cpp 140 | rpcs3/rpcs3/Input/ps_move_handler.cpp 141 | rpcs3/rpcs3/Input/ps_move_tracker.cpp 142 | rpcs3/rpcs3/Input/raw_mouse_config.cpp 143 | rpcs3/rpcs3/Input/raw_mouse_handler.cpp 144 | rpcs3/rpcs3/Input/sdl_pad_handler.cpp 145 | rpcs3/rpcs3/Input/skateboard_pad_handler.cpp 146 | rpcs3/rpcs3/rpcs3_version.cpp 147 | ) 148 | 149 | target_include_directories(${CMAKE_PROJECT_NAME} PUBLIC rpcs3/rpcs3) 150 | 151 | target_link_libraries(${CMAKE_PROJECT_NAME} 152 | android 153 | log 154 | rpcs3_emu 155 | nativehelper 156 | 3rdparty::libusb 157 | 3rdparty::hidapi 158 | 3rdparty::wolfssl 159 | 3rdparty::libcurl 160 | 3rdparty::zlib 161 | 3rdparty::fusion 162 | ) 163 | -------------------------------------------------------------------------------- /app/src/main/cpp/block_dev.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "Utilities/File.h" 4 | #include 5 | #include 6 | 7 | class block_dev { 8 | std::size_t m_block_size = 0; 9 | std::size_t m_block_count = 0; 10 | 11 | public: 12 | virtual ~block_dev() = default; 13 | 14 | std::size_t block_size() const { return m_block_size; } 15 | std::size_t block_count() const { return m_block_count; } 16 | std::size_t size() const { return block_size() * block_count(); } 17 | 18 | virtual std::size_t read(std::size_t blockIndex, void *data, 19 | std::size_t blockCount) = 0; 20 | 21 | virtual std::size_t write(std::size_t blockIndex, const void *data, 22 | std::size_t blockCount) = 0; 23 | 24 | protected: 25 | void set_block_info(std::size_t size, std::size_t count) { 26 | m_block_size = size; 27 | m_block_count = count; 28 | } 29 | }; 30 | 31 | class file_block_dev final : public block_dev { 32 | fs::file m_file; 33 | 34 | public: 35 | explicit file_block_dev(fs::file file, std::size_t blockSize = 2048) 36 | : m_file(std::move(file)) { 37 | set_block_info(blockSize, m_file.size() / blockSize); 38 | } 39 | 40 | std::size_t read(std::size_t blockIndex, void *data, 41 | std::size_t blockCount) override { 42 | auto result = m_file.read_at(block_size() * blockIndex, data, 43 | blockCount * block_size()); 44 | return result / block_size(); 45 | } 46 | 47 | std::size_t write(std::size_t blockIndex, const void *data, 48 | std::size_t blockCount) override { 49 | auto result = m_file.write_at(block_size() * blockIndex, data, 50 | blockCount * block_size()); 51 | return result / block_size(); 52 | } 53 | 54 | fs::file &file() { return m_file; } 55 | fs::file release() { return std::exchange(m_file, {}); } 56 | }; 57 | 58 | class file_view_block_dev final : public block_dev { 59 | const fs::file *m_file; 60 | 61 | public: 62 | explicit file_view_block_dev(const fs::file &file, 63 | std::size_t blockSize = 2048) 64 | : m_file(&file) { 65 | set_block_info(blockSize, m_file->size() / blockSize); 66 | } 67 | 68 | std::size_t read(std::size_t blockIndex, void *data, 69 | std::size_t blockCount) override { 70 | auto result = m_file->read_at(block_size() * blockIndex, data, 71 | blockCount * block_size()); 72 | return result / block_size(); 73 | } 74 | 75 | std::size_t write(std::size_t blockIndex, const void *data, 76 | std::size_t blockCount) override { 77 | auto result = m_file->write_at(block_size() * blockIndex, data, 78 | blockCount * block_size()); 79 | return result / block_size(); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /app/src/main/cpp/compile_commands.json: -------------------------------------------------------------------------------- 1 | ../../../.cxx/tools/debug/arm64-v8a/compile_commands.json -------------------------------------------------------------------------------- /app/src/main/cpp/fs_provider.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "Utilities/File.h" 4 | #include 5 | #include 6 | 7 | class fs_provider { 8 | public: 9 | enum class handle : std::uintptr_t { 10 | invalid = ~static_cast(0) 11 | }; 12 | 13 | virtual ~fs_provider() = default; 14 | virtual fs::file open(const std::filesystem::path &path, 15 | fs::open_mode mode = fs::open_mode::read) = 0; 16 | virtual fs::dir open_dir(const std::filesystem::path &path) = 0; 17 | }; 18 | -------------------------------------------------------------------------------- /app/src/main/cpp/iso.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "Utilities/File.h" 4 | #include "block_dev.hpp" 5 | #include "fs_provider.hpp" 6 | #include "util/endian.hpp" 7 | #include "util/types.hpp" 8 | #include 9 | #include 10 | 11 | namespace iso { 12 | #pragma pack(push, 1) 13 | template struct le_be_pair { 14 | le_t le; 15 | be_t be; 16 | 17 | T value() const { 18 | if constexpr (std::endian::native == std::endian::little) { 19 | return le; 20 | } else { 21 | return be; 22 | } 23 | } 24 | }; 25 | 26 | struct PrimaryVolumeDescriptorDateTime { 27 | char year[4]; 28 | char month[2]; 29 | char day[2]; 30 | char hour[2]; 31 | char minute[2]; 32 | char second[2]; 33 | char milliseconds[2]; 34 | s8 gmt_offset; 35 | }; 36 | 37 | struct VolumeHeader { 38 | u8 type; 39 | char standard_id[5]; 40 | u8 version; 41 | }; 42 | 43 | struct DirDateTime { 44 | u8 year; // + 1900 45 | u8 month; 46 | u8 day; 47 | u8 hour; 48 | u8 minute; 49 | u8 second; 50 | s8 gmt_offset; 51 | 52 | struct tm to_tm() const { 53 | struct tm time{}; 54 | time.tm_year = year; 55 | time.tm_mon = month; 56 | time.tm_mday = day; 57 | time.tm_hour = hour; 58 | time.tm_min = minute; 59 | time.tm_sec = second; 60 | time.tm_gmtoff = gmt_offset; 61 | return time; 62 | } 63 | 64 | time_t to_time_t() const { 65 | auto tm = to_tm(); 66 | return mktime(&tm); 67 | } 68 | }; 69 | 70 | enum class DirEntryFlags : u8 { 71 | None = 0, 72 | Hidden = 1 << 0, 73 | Directory = 1 << 1, 74 | File = 1 << 2, 75 | ExtAttr = 1 << 3, 76 | Permissions = 1 << 4, 77 | }; 78 | 79 | constexpr DirEntryFlags operator&(DirEntryFlags lhs, DirEntryFlags rhs) { 80 | return static_cast(static_cast(lhs) & 81 | static_cast(rhs)); 82 | } 83 | constexpr DirEntryFlags operator|(DirEntryFlags lhs, DirEntryFlags rhs) { 84 | return static_cast(static_cast(lhs) | 85 | static_cast(rhs)); 86 | } 87 | 88 | struct DirEntry { 89 | u8 entry_length; 90 | u8 ext_attr_length; 91 | le_be_pair lba; 92 | le_be_pair length; 93 | DirDateTime create_time; 94 | DirEntryFlags flags; 95 | u8 interleave_unit_size; 96 | u8 interleave_gap_size; 97 | le_be_pair sequence; 98 | u8 filename_length; 99 | 100 | fs::stat_t to_fs_stat() const { 101 | fs::stat_t result{}; 102 | result.is_directory = 103 | (flags & iso::DirEntryFlags::Directory) != iso::DirEntryFlags::None; 104 | result.size = length.value(); 105 | result.ctime = create_time.to_time_t(); 106 | result.mtime = result.ctime; 107 | result.atime = result.ctime; 108 | return result; 109 | } 110 | fs::dir_entry to_fs_entry(std::string name) const { 111 | fs::dir_entry entry = {}; 112 | static_cast(entry) = to_fs_stat(); 113 | entry.name = std::move(name); 114 | return entry; 115 | } 116 | }; 117 | 118 | struct PrimaryVolumeDescriptor { 119 | VolumeHeader header; 120 | uint8_t pad0; 121 | char system_id[32]; 122 | char volume_id[32]; 123 | char pad1[8]; 124 | le_be_pair block_count; 125 | char pad2[32]; 126 | le_be_pair volume_set_size; 127 | le_be_pair vol_seq_num; 128 | le_be_pair block_size; 129 | le_be_pair path_table_size; 130 | le_t path_table_block_le; 131 | le_t ext_path_table_block_le; 132 | be_t path_table_block_be; 133 | be_t ext_path_table_block_be; 134 | DirEntry root; 135 | u8 pad3; 136 | uint8_t volume_set_id[128]; 137 | uint8_t publisher_id[128]; 138 | uint8_t data_preparer_id[128]; 139 | uint8_t application_id[128]; 140 | uint8_t copyright_file_id[37]; 141 | uint8_t abstract_file_id[37]; 142 | uint8_t bibliographical_file_id[37]; 143 | PrimaryVolumeDescriptorDateTime vol_creation_time; 144 | PrimaryVolumeDescriptorDateTime vol_modification_time; 145 | PrimaryVolumeDescriptorDateTime vol_expire_time; 146 | PrimaryVolumeDescriptorDateTime vol_effective_time; 147 | uint8_t version; 148 | uint8_t pad4; 149 | uint8_t app_used[512]; 150 | 151 | u32 path_table_block() const { 152 | if constexpr (std::endian::native == std::endian::little) { 153 | return path_table_block_le; 154 | } else { 155 | return path_table_block_be; 156 | } 157 | } 158 | }; 159 | 160 | struct PathTableEntryHeader { 161 | u8 name_length; 162 | u8 ext_attr_length; 163 | le_t location; 164 | le_t parent_id; 165 | }; 166 | 167 | enum class StringEncoding { 168 | ascii, 169 | utf16_be, 170 | }; 171 | 172 | #pragma pack(pop) 173 | } // namespace iso 174 | 175 | class iso_fs final : public fs_provider { 176 | std::unique_ptr m_dev; 177 | iso::DirEntry m_root_dir; 178 | iso::StringEncoding m_encoding = iso::StringEncoding::ascii; 179 | 180 | public: 181 | iso_fs() = default; 182 | 183 | static std::optional open(std::unique_ptr device) { 184 | iso_fs result; 185 | result.m_dev = std::move(device); 186 | 187 | if (!result.initialize()) { 188 | return {}; 189 | } 190 | 191 | return result; 192 | } 193 | 194 | fs::file open(const std::filesystem::path &path, 195 | fs::open_mode mode = ::fs::open_mode::read) override; 196 | fs::dir open_dir(const std::filesystem::path &path) override; 197 | 198 | private: 199 | bool initialize(); 200 | 201 | std::optional open_entry(const std::filesystem::path &path, 202 | bool isDir); 203 | 204 | std::pair, std::vector> 205 | read_dir(const iso::DirEntry &entry); 206 | fs::file read_file(const iso::DirEntry &entry); 207 | }; 208 | -------------------------------------------------------------------------------- /app/src/main/ic_rpcs3-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/ic_rpcs3-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/FirmwareRepository.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.content.res.Resources.NotFoundException 4 | import androidx.compose.runtime.MutableState 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.annotation.Keep 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.json.Json 9 | import java.io.File 10 | 11 | 12 | enum class FirmwareStatus { 13 | None, 14 | Installed, 15 | Compiled 16 | } 17 | 18 | @Serializable 19 | private data class FirmwareInfo(val version: String?, val status: FirmwareStatus); 20 | 21 | class FirmwareRepository { 22 | companion object { 23 | val progressChannel: MutableState = mutableStateOf(null) 24 | val version: MutableState = mutableStateOf(null) 25 | val status: MutableState = mutableStateOf(FirmwareStatus.None) 26 | 27 | fun save() { 28 | try { 29 | File(RPCS3.rootDirectory + "fw.json").writeText( 30 | Json.encodeToString( 31 | FirmwareInfo(version.value, status.value) 32 | ) 33 | ) 34 | } catch (e: Exception) { 35 | e.printStackTrace() 36 | 37 | } 38 | } 39 | 40 | fun load() { 41 | try { 42 | val info = 43 | Json.decodeFromString(File(RPCS3.rootDirectory + "fw.json").readText()) 44 | status.value = info.status 45 | version.value = info.version 46 | } catch (_: NotFoundException) { 47 | } catch (e: Exception) { 48 | e.printStackTrace() 49 | } 50 | } 51 | 52 | @Keep 53 | @JvmStatic 54 | fun onFirmwareInstalled(version: String?) { 55 | updateStatus(version, FirmwareStatus.Installed) 56 | } 57 | 58 | @Keep 59 | @JvmStatic 60 | fun onFirmwareCompiled(version: String?) { 61 | updateStatus(version, FirmwareStatus.Compiled) 62 | } 63 | 64 | fun updateStatus(version: String?, status: FirmwareStatus) { 65 | synchronized(Companion.version) { 66 | Companion.version.value = version 67 | Companion.status.value = status 68 | 69 | save() 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/GraphicsFrame.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.SurfaceHolder 6 | import android.view.SurfaceView 7 | import kotlin.concurrent.thread 8 | 9 | class GraphicsFrame : SurfaceView, SurfaceHolder.Callback { 10 | constructor(context: Context) : super(context) { 11 | holder.addCallback(this) 12 | } 13 | 14 | constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { 15 | holder.addCallback(this) 16 | } 17 | 18 | constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( 19 | context, 20 | attrs, 21 | defStyleAttr 22 | ) { 23 | holder.addCallback(this) 24 | } 25 | 26 | constructor( 27 | context: Context?, 28 | attrs: AttributeSet?, 29 | defStyleAttr: Int, 30 | defStyleRes: Int 31 | ) : super(context, attrs, defStyleAttr, defStyleRes) { 32 | holder.addCallback(this) 33 | } 34 | 35 | override fun surfaceCreated(p0: SurfaceHolder) { 36 | RPCS3.instance.surfaceEvent(p0.surface, 0) 37 | } 38 | 39 | override fun surfaceChanged(p0: SurfaceHolder, p1: Int, p2: Int, p3: Int) { 40 | RPCS3.instance.surfaceEvent(p0.surface, 1) 41 | } 42 | 43 | override fun surfaceDestroyed(p0: SurfaceHolder) { 44 | RPCS3.instance.surfaceEvent(p0.surface, 2) 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.os.Bundle 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import androidx.core.view.WindowCompat 10 | import androidx.lifecycle.lifecycleScope 11 | import kotlinx.coroutines.launch 12 | import net.rpcs3.ui.navigation.AppNavHost 13 | import kotlin.concurrent.thread 14 | 15 | private const val ACTION_USB_PERMISSION = "net.rpcs3.USB_PERMISSION" 16 | 17 | class MainActivity : ComponentActivity() { 18 | private lateinit var unregisterUsbEventListener: () -> Unit 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | 22 | WindowCompat.setDecorFitsSystemWindows(window, false) 23 | 24 | setContent { 25 | RPCS3Theme { 26 | AppNavHost() 27 | } 28 | } 29 | 30 | RPCS3.rootDirectory = applicationContext.getExternalFilesDir(null).toString() 31 | if (!RPCS3.rootDirectory.endsWith("/")) { 32 | RPCS3.rootDirectory += "/" 33 | } 34 | 35 | if (!RPCS3.initialized) { 36 | lifecycleScope.launch { GameRepository.load() } 37 | FirmwareRepository.load() 38 | } 39 | 40 | Permission.PostNotifications.requestPermission(this) 41 | 42 | with(getSystemService(NOTIFICATION_SERVICE) as NotificationManager) { 43 | val channel = NotificationChannel( 44 | "rpcs3-progress", 45 | "Installation progress", 46 | NotificationManager.IMPORTANCE_DEFAULT 47 | ).apply { 48 | setShowBadge(false) 49 | lockscreenVisibility = Notification.VISIBILITY_PUBLIC 50 | } 51 | 52 | createNotificationChannel(channel) 53 | } 54 | 55 | if (!RPCS3.initialized) { 56 | RPCS3.instance.initialize(RPCS3.rootDirectory) 57 | val nativeLibraryDir = packageManager.getApplicationInfo(packageName, 0).nativeLibraryDir 58 | RPCS3.instance.settingsSet("Video@@Vulkan@@Custom Driver@@Hook Directory", "\"" + nativeLibraryDir + "\"") 59 | RPCS3.initialized = true 60 | 61 | thread { 62 | RPCS3.instance.startMainThreadProcessor() 63 | } 64 | 65 | thread { 66 | RPCS3.instance.processCompilationQueue() 67 | } 68 | } 69 | 70 | unregisterUsbEventListener = listenUsbEvents(this) 71 | } 72 | 73 | override fun onDestroy() { 74 | super.onDestroy() 75 | unregisterUsbEventListener() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/Permission.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.Manifest 4 | import android.annotation.SuppressLint 5 | import android.app.Activity 6 | import android.content.Context 7 | import android.content.pm.PackageManager 8 | import androidx.core.app.ActivityCompat 9 | import androidx.core.content.ContextCompat 10 | 11 | enum class Permission(val id: Int, val key: String) { 12 | @SuppressLint("InlinedApi") 13 | PostNotifications(100, Manifest.permission.POST_NOTIFICATIONS); 14 | 15 | fun checkPermission(context: Context) = 16 | ContextCompat.checkSelfPermission(context, key) == PackageManager.PERMISSION_GRANTED 17 | 18 | fun requestPermission(activity: Activity) { 19 | if (!checkPermission(activity)) { 20 | ActivityCompat.requestPermissions(activity, arrayOf(key), id) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/PrecompilerService.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.app.Service 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.pm.ServiceInfo 7 | import android.net.Uri 8 | import android.os.Build 9 | import android.os.IBinder 10 | import androidx.core.app.NotificationCompat 11 | import androidx.core.app.ServiceCompat 12 | import kotlin.concurrent.thread 13 | 14 | enum class PrecompilerServiceAction { 15 | InstallFirmware, 16 | Install 17 | } 18 | 19 | class PrecompilerService : Service() { 20 | companion object { 21 | fun start(context: Context, action: PrecompilerServiceAction, uri: Uri?) { 22 | val intent = Intent(context, PrecompilerService::class.java) 23 | intent.putExtra("action", action.ordinal) 24 | intent.putExtra("uri", uri) 25 | 26 | try { 27 | context.startForegroundService(intent) 28 | } catch (e: Exception) { 29 | e.printStackTrace() 30 | } 31 | } 32 | 33 | fun start(context: Context, action: PrecompilerServiceAction, batch: ArrayList) { 34 | if (batch.isEmpty()) { 35 | return 36 | } 37 | 38 | if (batch.size == 1) { 39 | start(context, action, batch[0]) 40 | return 41 | } 42 | 43 | val intent = Intent(context, PrecompilerService::class.java) 44 | intent.putExtra("action", action.ordinal) 45 | intent.putExtra("batch", batch) 46 | 47 | try { 48 | context.startForegroundService(intent) 49 | } catch (e: Exception) { 50 | e.printStackTrace() 51 | } 52 | } 53 | } 54 | 55 | override fun onBind(intent: Intent?): IBinder? { 56 | return null 57 | } 58 | 59 | override fun onCreate() { 60 | super.onCreate() 61 | } 62 | 63 | fun install(isFw: Boolean, uri: Uri, installProgress: Long): Boolean { 64 | val descriptor = contentResolver.openAssetFileDescriptor(uri, "r") 65 | val fd = descriptor?.parcelFileDescriptor?.fd 66 | 67 | if (fd == null) { 68 | try { 69 | descriptor?.close() 70 | } catch (e: Exception) { 71 | e.printStackTrace() 72 | } 73 | 74 | return false 75 | } 76 | 77 | val installResult = 78 | if (isFw) 79 | RPCS3.instance.installFw(fd, installProgress) 80 | else 81 | RPCS3.instance.install(fd, installProgress) 82 | 83 | if (!installResult) { 84 | try { 85 | ProgressRepository.onProgressEvent(installProgress, -1, 0) 86 | } catch (e: Exception) { 87 | e.printStackTrace() 88 | } 89 | } 90 | 91 | try { 92 | descriptor.close() 93 | } catch (e: Exception) { 94 | e.printStackTrace() 95 | } 96 | 97 | return true 98 | } 99 | 100 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 101 | val batch = intent?.getParcelableArrayListExtra("batch") 102 | val uri = intent?.getParcelableExtra("uri") 103 | val action = intent?.getIntExtra("action", 0) 104 | val isFwInstall = action == PrecompilerServiceAction.InstallFirmware.ordinal 105 | 106 | if (uri == null && batch == null) { 107 | stopSelf(startId) 108 | return START_NOT_STICKY 109 | } 110 | 111 | val installProgress = 112 | ProgressRepository.create( 113 | this, 114 | if (isFwInstall) "Firmware Installation" else "Package Installation" 115 | ) { entry -> 116 | if (entry.isFinished()) { 117 | if (isFwInstall) { 118 | FirmwareRepository.progressChannel.value = null 119 | } 120 | 121 | stopSelf(startId) 122 | } 123 | } 124 | 125 | if (isFwInstall) { 126 | FirmwareRepository.progressChannel.value = installProgress 127 | } 128 | 129 | try { 130 | ServiceCompat.startForeground( 131 | this, 132 | installProgress.toInt(), 133 | NotificationCompat.Builder(this, "rpcs3-progress").build(), 134 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 135 | ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE 136 | } else { 137 | 0 138 | } 139 | ) 140 | } catch (e: Exception) { 141 | e.printStackTrace() 142 | } 143 | 144 | thread { 145 | var installResult = false 146 | if (uri != null) { 147 | installResult = install(isFwInstall, uri, installProgress) 148 | } else batch?.forEach { uri -> 149 | // FIXME: create child progress 150 | if (install(isFwInstall, uri, installProgress)) { 151 | installResult = true 152 | } 153 | } 154 | 155 | if (!installResult) { 156 | stopSelf(startId) 157 | } 158 | } 159 | 160 | return START_STICKY 161 | } 162 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ProgressRepository.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.os.Message 8 | import androidx.annotation.Keep 9 | import androidx.compose.runtime.MutableLongState 10 | import androidx.compose.runtime.MutableState 11 | import androidx.compose.runtime.mutableLongStateOf 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.core.app.NotificationCompat 14 | import androidx.core.app.NotificationManagerCompat 15 | import net.rpcs3.dialogs.AlertDialogQueue 16 | import java.util.concurrent.ConcurrentHashMap 17 | 18 | data class ProgressEntry( 19 | val value: MutableLongState = mutableLongStateOf(0), 20 | val max: MutableLongState = mutableLongStateOf(0), 21 | val message: MutableState = mutableStateOf(null) 22 | ) { 23 | fun isComplete() = value.longValue == max.longValue && !isIndeterminate() 24 | fun isFailed() = value.longValue < 0 25 | fun isFinished() = isFailed() || isComplete() 26 | fun isIndeterminate() = max.longValue == 0L 27 | } 28 | 29 | data class ProgressUpdateEntry(val value: Long, val max: Long, val message: String?) { 30 | fun isComplete() = value == max && !isIndeterminate() 31 | fun isFailed() = value < 0 32 | fun isFinished() = isFailed() || isComplete() 33 | fun isIndeterminate() = max == 0L 34 | } 35 | 36 | private data class ProgressWithHandler( 37 | var handler: (ProgressUpdateEntry) -> Unit, 38 | val progressEntry: MutableState 39 | ) 40 | 41 | class ProgressRepository { 42 | private var progressHandlers = ConcurrentHashMap() 43 | private var nextRequestId = 1L 44 | 45 | companion object { 46 | private val instance = ProgressRepository() 47 | 48 | fun getItem(id: Long?) = 49 | if (id != null) instance.progressHandlers[id]?.progressEntry else null 50 | 51 | @Keep 52 | @JvmStatic 53 | fun onProgressEvent(id: Long, value: Long, max: Long, message: String? = null): Boolean { 54 | val item = instance.progressHandlers[id] ?: return false 55 | 56 | item.progressEntry.value.apply { 57 | this.value.longValue = value 58 | this.max.longValue = max 59 | this.message.value = message ?: this.message.value 60 | } 61 | 62 | item.handler(ProgressUpdateEntry(value, max, item.progressEntry.value.message.value)) 63 | 64 | if (item.progressEntry.value.isFinished()) { 65 | cancel(id) 66 | } 67 | 68 | return true 69 | } 70 | 71 | fun cancel(id: Long) { 72 | instance.progressHandlers.remove(id) 73 | GameRepository.clearProgress(id) 74 | } 75 | 76 | fun create( 77 | context: Context, 78 | title: String, 79 | silent: Boolean = false, 80 | handler: (ProgressUpdateEntry) -> Unit = { _ -> } 81 | ): Long { 82 | var requestId: Long 83 | val entry = ProgressWithHandler(handler, mutableStateOf(ProgressEntry())) 84 | while (true) { 85 | requestId = instance.nextRequestId++ 86 | if (instance.progressHandlers.put(requestId, entry) == null) { 87 | break 88 | } 89 | } 90 | 91 | val hasPermission = !silent && Permission.PostNotifications.checkPermission(context) 92 | 93 | val builder = NotificationCompat.Builder(context, "rpcs3-progress").apply { 94 | setContentTitle(title) 95 | setSmallIcon(R.drawable.ic_rpcs3_monochrome) 96 | setCategory(NotificationCompat.CATEGORY_SERVICE) 97 | setPriority(NotificationCompat.PRIORITY_DEFAULT) 98 | setProgress(0, 0, true) 99 | setSilent(true) 100 | } 101 | 102 | if (hasPermission) { 103 | with(NotificationManagerCompat.from(context)) { 104 | notify(requestId.toInt(), builder.build()) 105 | } 106 | } 107 | 108 | val asyncHandler = Handler.createAsync(Looper.getMainLooper()) { message -> 109 | val value = message.data.getLong("value") 110 | val max = message.data.getLong("max") 111 | val text = message.data.getString("message") 112 | 113 | if (hasPermission) { 114 | val notificationManager = NotificationManagerCompat.from(context) 115 | 116 | if (text != null) { 117 | builder.setContentText(text) 118 | } 119 | 120 | if (value >= 0 && max > 0) { 121 | if (value == max) { 122 | notificationManager.cancel(requestId.toInt()) 123 | } else { 124 | builder.setProgress(max.toInt(), value.toInt(), false) 125 | notificationManager.notify(requestId.toInt(), builder.build()) 126 | } 127 | } else if (value < 0) { 128 | val contentText = text ?: "Installation failed" 129 | builder.setContentText(contentText) 130 | AlertDialogQueue.showDialog(title, contentText) 131 | notificationManager.notify(requestId.toInt(), builder.build()) 132 | } else { 133 | builder.setProgress(max.toInt(), value.toInt(), true) 134 | notificationManager.notify(requestId.toInt(), builder.build()) 135 | } 136 | } 137 | 138 | handler(ProgressUpdateEntry(value, max, text)) 139 | true 140 | } 141 | 142 | entry.handler = { progress: ProgressUpdateEntry -> 143 | val message = Message() 144 | val data = Bundle() 145 | data.putLong("value", progress.value) 146 | data.putLong("max", progress.max) 147 | data.putString("message", progress.message) 148 | message.data = data 149 | asyncHandler.sendMessage(message) 150 | } 151 | 152 | return requestId 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/RPCS3.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.view.Surface 4 | import androidx.compose.runtime.mutableStateOf 5 | 6 | enum class Digital1Flags(val bit: Int) 7 | { 8 | None(0), 9 | CELL_PAD_CTRL_SELECT(0x00000001), 10 | CELL_PAD_CTRL_L3(0x00000002), 11 | CELL_PAD_CTRL_R3(0x00000004), 12 | CELL_PAD_CTRL_START(0x00000008), 13 | CELL_PAD_CTRL_UP(0x00000010), 14 | CELL_PAD_CTRL_RIGHT(0x00000020), 15 | CELL_PAD_CTRL_DOWN(0x00000040), 16 | CELL_PAD_CTRL_LEFT(0x00000080), 17 | CELL_PAD_CTRL_PS(0x00000100), 18 | } 19 | 20 | enum class Digital2Flags(val bit: Int) 21 | { 22 | None(0), 23 | CELL_PAD_CTRL_L2(0x00000001), 24 | CELL_PAD_CTRL_R2(0x00000002), 25 | CELL_PAD_CTRL_L1(0x00000004), 26 | CELL_PAD_CTRL_R1(0x00000008), 27 | CELL_PAD_CTRL_TRIANGLE(0x00000010), 28 | CELL_PAD_CTRL_CIRCLE(0x00000020), 29 | CELL_PAD_CTRL_CROSS(0x00000040), 30 | CELL_PAD_CTRL_SQUARE(0x00000080), 31 | }; 32 | 33 | enum class EmulatorState { 34 | Stopped, 35 | Loading, 36 | Stopping, 37 | Running, 38 | Paused, 39 | Frozen, // paused but cannot resume 40 | Ready, 41 | Starting; 42 | 43 | companion object { 44 | fun fromInt(value: Int) = EmulatorState.entries.first { it.ordinal == value } 45 | } 46 | } 47 | 48 | enum class BootResult 49 | { 50 | NoErrors, 51 | GenericError, 52 | NothingToBoot, 53 | WrongDiscLocation, 54 | InvalidFileOrFolder, 55 | InvalidBDvdFolder, 56 | InstallFailed, 57 | DecryptionError, 58 | FileCreationError, 59 | FirmwareMissing, 60 | UnsupportedDiscType, 61 | SavestateCorrupted, 62 | SavestateVersionUnsupported, 63 | StillRunning, 64 | AlreadyAdded, 65 | CurrentlyRestricted; 66 | 67 | companion object { 68 | fun fromInt(value: Int) = entries.first { it.ordinal == value } 69 | } 70 | }; 71 | 72 | class RPCS3 { 73 | external fun initialize(rootDir: String): Boolean 74 | external fun installFw(fd: Int, progressId: Long): Boolean 75 | external fun install(fd: Int, progressId: Long): Boolean 76 | external fun installKey(fd: Int, requestId: Long, gamePath: String): Boolean 77 | external fun boot(path: String): Int 78 | external fun surfaceEvent(surface: Surface, event: Int): Boolean 79 | external fun usbDeviceEvent(fd: Int, vendorId: Int, productId: Int, event: Int): Boolean 80 | external fun processCompilationQueue(): Boolean 81 | external fun startMainThreadProcessor(): Boolean 82 | external fun overlayPadData(digital1: Int, digital2: Int, leftStickX: Int, leftStickY: Int, rightStickX: Int, rightStickY: Int): Boolean 83 | external fun collectGameInfo(rootDir: String, progressId: Long): Boolean 84 | external fun systemInfo(): String 85 | external fun settingsGet(path: String): String 86 | external fun settingsSet(path: String, value: String): Boolean 87 | external fun getState() : Int 88 | external fun kill() 89 | external fun resume() 90 | external fun openHomeMenu() 91 | external fun getTitleId(): String 92 | external fun supportsCustomDriverLoading() : Boolean 93 | external fun isInstallableFile(fd: Int) : Boolean 94 | external fun getDirInstallPath(sfoFd: Int) : String? 95 | // external fun forceMaxGpuClocks(enable : Boolean) 96 | 97 | 98 | companion object { 99 | var initialized = false 100 | val instance = RPCS3() 101 | var rootDirectory: String = "" 102 | var activeGame = mutableStateOf(null) 103 | var state = mutableStateOf(EmulatorState.Stopped) 104 | 105 | fun boot(path: String): BootResult { 106 | return BootResult.fromInt(instance.boot(path)) 107 | } 108 | 109 | fun updateState() { 110 | val newState = EmulatorState.fromInt(instance.getState()) 111 | if (newState != state.value) { 112 | state.value = newState 113 | } 114 | } 115 | 116 | fun getState(): EmulatorState { 117 | updateState() 118 | return state.value 119 | } 120 | 121 | init { 122 | System.loadLibrary("rpcs3-android") 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/RPCS3Theme.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3 3 | 4 | import androidx.compose.material3.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import android.app.Activity 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.platform.LocalView 11 | import androidx.core.view.WindowCompat 12 | import androidx.core.view.WindowInsetsControllerCompat 13 | 14 | @Composable 15 | fun RPCS3Theme( 16 | darkTheme: Boolean = isSystemInDarkTheme(), 17 | content: @Composable () -> Unit 18 | ) { 19 | // TODO(Ishan09811): Implement dynamic colors option whenever settings gets implemented 20 | val colors = if (darkTheme) darkColorScheme() else lightColorScheme() 21 | 22 | val view = LocalView.current 23 | val activity = view.context as? Activity 24 | 25 | SideEffect { 26 | activity?.window?.apply { 27 | statusBarColor = android.graphics.Color.TRANSPARENT 28 | navigationBarColor = android.graphics.Color.TRANSPARENT 29 | isNavigationBarContrastEnforced = false 30 | val insetsController = WindowInsetsControllerCompat(this, decorView) 31 | insetsController.isAppearanceLightNavigationBars = !darkTheme 32 | insetsController.isAppearanceLightStatusBars = !darkTheme 33 | } 34 | } 35 | 36 | MaterialTheme( 37 | colorScheme = colors, 38 | typography = Typography(), 39 | content = content 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/UsbDeviceRepository.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3 2 | 3 | import android.app.Activity.USB_SERVICE 4 | import android.app.PendingIntent 5 | import android.content.BroadcastReceiver 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.IntentFilter 9 | import android.hardware.usb.UsbDevice 10 | import android.hardware.usb.UsbDeviceConnection 11 | import android.hardware.usb.UsbManager 12 | import android.os.Build 13 | import android.util.Log 14 | 15 | private const val ACTION_USB_PERMISSION = "net.rpcs3.USB_PERMISSION" 16 | 17 | fun listenUsbEvents(context: Context): () -> Unit { 18 | val mPermissionIntent = PendingIntent.getBroadcast( 19 | context, 20 | 0, 21 | Intent(ACTION_USB_PERMISSION), 22 | PendingIntent.FLAG_MUTABLE or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 23 | PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT 24 | } else { 25 | 0 26 | } 27 | ) 28 | val usbManager = context.getSystemService(USB_SERVICE) as UsbManager 29 | 30 | val usbReceiver = object : BroadcastReceiver() { 31 | override fun onReceive(context: Context, intent: Intent) { 32 | if (UsbManager.ACTION_USB_DEVICE_DETACHED == intent.action) { 33 | Log.i("USB", "device detached") 34 | val device: UsbDevice? = with(intent) { 35 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 36 | getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) 37 | else 38 | getParcelableExtra(UsbManager.EXTRA_DEVICE) 39 | } 40 | if (device != null) { 41 | UsbDeviceRepository.detach(device) 42 | } 43 | 44 | return 45 | } 46 | 47 | if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) { 48 | val device: UsbDevice? = with(intent) { 49 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 50 | getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) 51 | else 52 | getParcelableExtra(UsbManager.EXTRA_DEVICE) 53 | } 54 | Log.i("USB", "device attached") 55 | if (device != null) { 56 | if (usbManager.hasPermission(device)) { 57 | Log.i("USB", "permission granted, attaching") 58 | UsbDeviceRepository.attach(device, usbManager) 59 | } else { 60 | Log.i("USB", "no permission, requesting") 61 | usbManager.requestPermission(device, mPermissionIntent) 62 | } 63 | } 64 | 65 | return 66 | } 67 | 68 | if (ACTION_USB_PERMISSION == intent.action) { 69 | val device: UsbDevice? = with(intent) { 70 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 71 | getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) 72 | else 73 | getParcelableExtra(UsbManager.EXTRA_DEVICE) 74 | } 75 | 76 | if (device != null && intent.getBooleanExtra( 77 | UsbManager.EXTRA_PERMISSION_GRANTED, 78 | false 79 | ) 80 | ) { 81 | if (usbManager.hasPermission(device)) { 82 | Log.i("USB", "permission granted, attaching") 83 | UsbDeviceRepository.attach(device, usbManager) 84 | } else { 85 | Log.i("USB", "no permission after request") 86 | } 87 | } else { 88 | Log.i("USB", "permission request aborted") 89 | } 90 | } 91 | } 92 | } 93 | 94 | val filter = IntentFilter() 95 | filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) 96 | filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) 97 | filter.addAction(ACTION_USB_PERMISSION) 98 | context.registerReceiver(usbReceiver, filter, Context.RECEIVER_EXPORTED) 99 | 100 | for (usbDevice in usbManager.deviceList.values) { 101 | if (usbManager.hasPermission(usbDevice)) { 102 | UsbDeviceRepository.attach(usbDevice, usbManager) 103 | } else { 104 | usbManager.requestPermission(usbDevice, mPermissionIntent) 105 | } 106 | } 107 | 108 | return { 109 | context.unregisterReceiver(usbReceiver) 110 | } 111 | } 112 | 113 | class UsbDeviceRepository { 114 | companion object { 115 | private val devices = HashMap() 116 | 117 | fun attach(device: UsbDevice, usbManager: UsbManager) { 118 | if (devices[device] != null) { 119 | return 120 | } 121 | 122 | val connection = usbManager.openDevice(device) 123 | devices[device] = connection 124 | RPCS3.instance.usbDeviceEvent( 125 | connection.fileDescriptor, 126 | device.vendorId, 127 | device.productId, 128 | 0 129 | ) 130 | } 131 | 132 | fun detach(device: UsbDevice) { 133 | val connection = devices[device] 134 | if (connection != null) { 135 | RPCS3.instance.usbDeviceEvent(connection.fileDescriptor, -1, -1, 1) 136 | connection.close() 137 | 138 | devices.remove(device) 139 | } 140 | } 141 | } 142 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/dialogs/AlertDialogQueue.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3.dialogs 3 | 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.heightIn 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.verticalScroll 16 | import androidx.compose.material3.BasicAlertDialog 17 | import androidx.compose.material3.Text 18 | import androidx.compose.material3.TextButton 19 | import androidx.compose.material3.Divider 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Surface 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.mutableStateListOf 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.derivedStateOf 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.unit.dp 29 | 30 | object AlertDialogQueue { 31 | val dialogs = mutableStateListOf() 32 | 33 | fun showDialog( 34 | title: String, 35 | message: String, 36 | onConfirm: () -> Unit = {}, 37 | onDismiss: (() -> Unit)? = null, 38 | confirmText: String = "OK", 39 | dismissText: String = "Cancel" 40 | ) { 41 | dialogs.add(DialogData(title, message, onConfirm, onDismiss, confirmText, dismissText)) 42 | } 43 | 44 | private fun dismissDialog() { 45 | if (dialogs.isNotEmpty()) { 46 | dialogs.removeAt(0) 47 | } 48 | } 49 | 50 | @OptIn(ExperimentalMaterial3Api::class) 51 | @Composable 52 | fun AlertDialog() { 53 | if (dialogs.isEmpty()) { 54 | return 55 | } 56 | 57 | val dialog = dialogs.first() 58 | val onDismiss = dialog.onDismiss 59 | 60 | val scrollState = rememberScrollState() 61 | val hasScrolled = remember { derivedStateOf { scrollState.value > 0 } } 62 | 63 | BasicAlertDialog( 64 | onDismissRequest = { 65 | onDismiss?.invoke() 66 | dismissDialog() 67 | }, 68 | content = { 69 | Surface( 70 | shape = RoundedCornerShape(28.dp), 71 | tonalElevation = 6.dp, 72 | modifier = Modifier 73 | .fillMaxWidth() 74 | .padding(vertical = 16.dp) 75 | ) { 76 | Column(modifier = Modifier.padding(vertical = 16.dp)) { 77 | Text( 78 | dialog.title, 79 | modifier = Modifier.padding(horizontal = 16.dp), 80 | style = MaterialTheme.typography.headlineSmall, 81 | color = MaterialTheme.colorScheme.onSurface 82 | ) 83 | Spacer(modifier = Modifier.height(8.dp)) 84 | 85 | if (hasScrolled.value) { 86 | Divider() 87 | } 88 | 89 | Text( 90 | text = dialog.message, 91 | style = MaterialTheme.typography.bodyMedium, 92 | color = MaterialTheme.colorScheme.onSurfaceVariant, 93 | modifier = Modifier 94 | .heightIn(max = 200.dp) 95 | .verticalScroll(scrollState) 96 | .padding(vertical = 4.dp, horizontal = 16.dp) 97 | ) 98 | 99 | if (hasScrolled.value) { 100 | Divider() 101 | } 102 | 103 | Spacer(modifier = Modifier.height(16.dp)) 104 | Row( 105 | modifier = Modifier.fillMaxWidth(), 106 | horizontalArrangement = Arrangement.End 107 | ) { 108 | TextButton( 109 | onClick = { 110 | onDismiss?.invoke() 111 | dismissDialog() 112 | } 113 | ) { 114 | Text(text = dialog.dismissText) 115 | } 116 | Spacer(modifier = Modifier.width(8.dp)) 117 | TextButton( 118 | onClick = { 119 | dialog.onConfirm() 120 | onDismiss?.invoke() 121 | dismissDialog() 122 | }, 123 | modifier = Modifier.padding(end = 16.dp) 124 | ) { 125 | Text(text = dialog.confirmText) 126 | } 127 | } 128 | } 129 | } 130 | } 131 | ) 132 | } 133 | } 134 | 135 | data class DialogData( 136 | val title: String, 137 | val message: String, 138 | val onConfirm: () -> Unit, 139 | val onDismiss: (() -> Unit)?, 140 | val confirmText: String, 141 | val dismissText: String 142 | ) 143 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/overlay/PadOverlayButton.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.overlay 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.content.res.Resources 6 | import android.graphics.Bitmap 7 | import android.graphics.drawable.BitmapDrawable 8 | import android.view.MotionEvent 9 | import kotlin.math.roundToInt 10 | 11 | class PadOverlayButton(private val context: Context, resources: Resources, image: Bitmap, private val digital1: Int, private val digital2: Int) : BitmapDrawable(resources, image) { 12 | private var pressed = false 13 | private var locked = -1 14 | private var origAlpha = alpha 15 | var dragging = false 16 | private var offsetX = 0 17 | private var offsetY = 0 18 | private var scaleFactor = 0.5f 19 | private var opacity = alpha 20 | var defaultSize: Pair = Pair(-1, -1) 21 | lateinit var defaultPosition: Pair 22 | private val prefs: SharedPreferences by lazy { context.getSharedPreferences("PadOverlayPrefs", Context.MODE_PRIVATE) } 23 | 24 | fun contains(x: Int, y: Int) = bounds.contains(x, y) 25 | 26 | fun onTouch(event: MotionEvent, pointerIndex: Int, padState: State): Boolean { 27 | val action = event.actionMasked 28 | var hit = false 29 | if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { 30 | if (locked == -1) { 31 | locked = event.getPointerId(pointerIndex) 32 | pressed = true 33 | origAlpha = alpha 34 | alpha = 255 35 | hit = true 36 | } 37 | } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_CANCEL) { 38 | if (locked != -1 && (action == MotionEvent.ACTION_CANCEL || event.getPointerId(pointerIndex) == locked)) { 39 | pressed = false 40 | locked = -1 41 | alpha = origAlpha 42 | hit = true 43 | } 44 | } 45 | 46 | if (pressed) { 47 | padState.digital[0] = padState.digital[0] or digital1 48 | padState.digital[1] = padState.digital[1] or digital2 49 | } else { 50 | padState.digital[0] = padState.digital[0] and digital1.inv() 51 | padState.digital[1] = padState.digital[1] and digital2.inv() 52 | } 53 | 54 | return hit 55 | } 56 | 57 | fun startDragging(startX: Int, startY: Int) { 58 | dragging = true 59 | offsetX = startX - bounds.left 60 | offsetY = startY - bounds.top 61 | } 62 | 63 | fun updatePosition(x: Int, y: Int, force: Boolean = false) { 64 | if (dragging) { 65 | setBounds(x - offsetX, y - offsetY, x - offsetX + bounds.width(), y - offsetY + bounds.height()) 66 | prefs.edit() 67 | .putInt("button_${digital1}_${digital2}_x", x - offsetX) 68 | .putInt("button_${digital1}_${digital2}_y", y - offsetY) 69 | .apply() 70 | } else if (force) { 71 | // don't use offsets as we aren't dragging 72 | setBounds(x, y, x + bounds.width(), y + bounds.height()) 73 | prefs.edit() 74 | .putInt("button_${digital1}_${digital2}_x", x) 75 | .putInt("button_${digital1}_${digital2}_y", y) 76 | .apply() 77 | } 78 | } 79 | 80 | fun stopDragging() { 81 | dragging = false 82 | } 83 | 84 | fun setScale(percent: Int) { 85 | scaleFactor = percent / 100f 86 | val newWidth = (intrinsicWidth * scaleFactor).roundToInt() 87 | val newHeight = (intrinsicHeight * scaleFactor).roundToInt() 88 | setBounds(bounds.left, bounds.top, bounds.left + newWidth, bounds.top + newHeight) 89 | prefs.edit().putInt("button_${digital1}_${digital2}_scale", percent).apply() 90 | } 91 | 92 | fun setOpacity(percent: Int) { 93 | opacity = (255 * (percent / 100f)).roundToInt() 94 | alpha = opacity 95 | prefs.edit().putInt("button_${digital1}_${digital2}_opacity", percent).apply() 96 | } 97 | 98 | fun measureDefaultScale(): Int { 99 | if (defaultSize.second <= 0 || defaultSize.first <= 0) return 100 100 | val widthScale = defaultSize.second.toFloat() / intrinsicWidth * 100 101 | val heightScale = defaultSize.first.toFloat() / intrinsicHeight * 100 102 | return minOf(widthScale, heightScale).roundToInt() 103 | } 104 | 105 | fun resetConfigs() { 106 | setOpacity(50) 107 | setBounds(defaultPosition.first, defaultPosition.second, defaultPosition.first + defaultSize.second, defaultPosition.second + defaultSize.first) 108 | prefs.edit() 109 | .remove("button_${digital1}_${digital2}_scale") 110 | .remove("button_${digital1}_${digital2}_opacity") 111 | .remove("button_${digital1}_${digital2}_x") 112 | .remove("button_${digital1}_${digital2}_y") 113 | .apply() 114 | } 115 | 116 | fun getInfo(): Triple { 117 | return Triple("${digital1}_${digital2}", prefs.getInt("button_${digital1}_${digital2}_scale", measureDefaultScale()), prefs.getInt("button_${digital1}_${digital2}_opacity", 50)) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/overlay/PadOverlayStick.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.overlay 2 | 3 | import android.content.res.Resources 4 | import android.graphics.Bitmap 5 | import android.graphics.Canvas 6 | import android.graphics.drawable.BitmapDrawable 7 | import android.view.MotionEvent 8 | import androidx.core.graphics.drawable.toDrawable 9 | import kotlin.math.atan2 10 | import kotlin.math.cos 11 | import kotlin.math.hypot 12 | import kotlin.math.sin 13 | 14 | class PadOverlayStick( 15 | resources: Resources, 16 | private val isLeft: Boolean, 17 | bg: Bitmap, 18 | stick: Bitmap, 19 | private val pressDigitalIndex: Int = 0, 20 | private val pressBit: Int = 0 21 | ) : 22 | BitmapDrawable(resources, stick) { 23 | private var bg = bg.toDrawable(resources) 24 | private var locked = -1 25 | private var pressX = -1 26 | private var pressY = -1 27 | private var bgOffsetX = 0 28 | private var bgOffsetY = 0 29 | fun contains(x: Int, y: Int) = bounds.contains(x, y) 30 | 31 | fun isActive(): Boolean { 32 | return locked != -1 33 | } 34 | 35 | fun onAdd(event: MotionEvent, pointerIndex: Int) { 36 | locked = event.getPointerId(pointerIndex) 37 | val x = event.getX(pointerIndex).toInt() 38 | val y = event.getY(pointerIndex).toInt() 39 | 40 | pressX = x 41 | pressY = y 42 | 43 | setBounds( 44 | x - bounds.width() / 2, 45 | y - bounds.height() / 2, 46 | x + bounds.width() / 2, 47 | y + bounds.height() / 2, 48 | ) 49 | } 50 | 51 | fun onTouch(event: MotionEvent, pointerIndex: Int, padState: State): Int { 52 | val action = event.actionMasked 53 | 54 | if ((pressBit != 0 && (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN)) || (locked != -1 && action == MotionEvent.ACTION_MOVE)) { 55 | var activePointerIndex = pointerIndex 56 | 57 | if (action != MotionEvent.ACTION_MOVE) { 58 | if (locked == -1) { 59 | locked = event.getPointerId(pointerIndex) 60 | pressX = event.getX(pointerIndex).toInt() 61 | pressY = event.getY(pointerIndex).toInt() 62 | bgOffsetX = bg.bounds.centerX() - pressX 63 | bgOffsetY = bg.bounds.centerY() - pressY 64 | 65 | bg.setBounds(bg.bounds.left - bgOffsetX,bg.bounds.top - bgOffsetY, bg.bounds.right - bgOffsetX, bg.bounds.bottom - bgOffsetY) 66 | } else if (locked != event.getPointerId(pointerIndex)) { 67 | return 0 68 | } 69 | } else { 70 | 71 | for (i in 0.. bgR) { 99 | val L = atan2(y, x) 100 | x = bgR * cos(L) 101 | y = bgR * sin(L) 102 | } 103 | 104 | val stickX = ((x / bgR) * 127 + 128).toInt() 105 | val stickY = ((y / bgR) * 127 + 128).toInt() 106 | 107 | if (isLeft) { 108 | padState.leftStickX = stickX 109 | padState.leftStickY = stickY 110 | } else { 111 | padState.rightStickX = stickX 112 | padState.rightStickY = stickY 113 | } 114 | 115 | x += bgCenterX 116 | y += bgCenterY 117 | 118 | super.setBounds( 119 | x.toInt() - bounds.width() / 2, 120 | y.toInt() - bounds.height() / 2, 121 | x.toInt() + bounds.width() / 2, 122 | y.toInt() + bounds.height() / 2, 123 | ) 124 | 125 | return 1 126 | } 127 | 128 | if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_CANCEL) { 129 | if (locked != -1 && (action == MotionEvent.ACTION_CANCEL || event.getPointerId( 130 | pointerIndex 131 | ) == locked) 132 | ) { 133 | locked = -1 134 | 135 | bg.setBounds(bg.bounds.left + bgOffsetX,bg.bounds.top + bgOffsetY, bg.bounds.right + bgOffsetX, bg.bounds.bottom + bgOffsetY) 136 | bgOffsetY = 0 137 | bgOffsetX = 0 138 | 139 | padState.digital[pressDigitalIndex] = 140 | padState.digital[pressDigitalIndex] and pressBit.inv() 141 | 142 | super.setBounds(bg.bounds) 143 | 144 | if (isLeft) { 145 | padState.leftStickX = 127 146 | padState.leftStickY = 127 147 | } else { 148 | padState.rightStickX = 127 149 | padState.rightStickY = 127 150 | } 151 | return -1 152 | } 153 | } 154 | 155 | return 0 156 | } 157 | 158 | override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { 159 | super.setBounds(left, top, right, bottom) 160 | bg.setBounds(left, top, right, bottom) 161 | } 162 | 163 | override fun setAlpha(alpha: Int) { 164 | super.setAlpha(alpha) 165 | bg.alpha = alpha 166 | } 167 | 168 | override fun draw(canvas: Canvas) { 169 | bg.draw(canvas) 170 | super.draw(canvas) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/provider/AppDataDocumentProvider.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.provider 2 | 3 | import android.content.Context 4 | import android.database.Cursor 5 | import android.database.MatrixCursor 6 | import android.os.CancellationSignal 7 | import android.os.ParcelFileDescriptor 8 | import android.provider.DocumentsContract.Document 9 | import android.provider.DocumentsContract.Root 10 | import android.provider.DocumentsProvider 11 | import net.rpcs3.R 12 | import java.io.File 13 | import java.io.FileNotFoundException 14 | import java.io.IOException 15 | 16 | class AppDataDocumentProvider : DocumentsProvider() { 17 | companion object { 18 | const val ROOT_ID = "root" 19 | const val AUTHORITY = "net.rpcs3" + ".documents" 20 | 21 | private val DEFAULT_ROOT_PROJECTION = arrayOf( 22 | Root.COLUMN_ROOT_ID, 23 | Root.COLUMN_MIME_TYPES, 24 | Root.COLUMN_FLAGS, 25 | Root.COLUMN_ICON, 26 | Root.COLUMN_TITLE, 27 | Root.COLUMN_SUMMARY, 28 | Root.COLUMN_DOCUMENT_ID, 29 | Root.COLUMN_AVAILABLE_BYTES 30 | ) 31 | 32 | private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( 33 | Document.COLUMN_DOCUMENT_ID, 34 | Document.COLUMN_DISPLAY_NAME, 35 | Document.COLUMN_MIME_TYPE, 36 | Document.COLUMN_LAST_MODIFIED, 37 | Document.COLUMN_FLAGS, 38 | Document.COLUMN_SIZE 39 | ) 40 | } 41 | 42 | private fun obtainDocumentId(file: File): String { 43 | val basePath = baseDirectory().absolutePath 44 | val fullPath = file.absolutePath 45 | return (ROOT_ID + "/" + fullPath.substring(basePath.length)).replace("//", "/") 46 | } 47 | 48 | private fun obtainFile(documentId: String): File { 49 | require(documentId.startsWith(ROOT_ID)) { "Invalid document id: $documentId" } 50 | return File(baseDirectory(), documentId.substring(ROOT_ID.length)) 51 | } 52 | 53 | private fun context(): Context = context!! 54 | 55 | private fun baseDirectory(): File = context().getExternalFilesDir(null)!! 56 | 57 | override fun onCreate(): Boolean = true 58 | 59 | override fun queryRoots(projection: Array?): Cursor { 60 | val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) 61 | cursor.newRow() 62 | .add(Root.COLUMN_ROOT_ID, ROOT_ID) 63 | .add(Root.COLUMN_SUMMARY, null) 64 | .add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_SUPPORTS_CREATE) 65 | .add(Root.COLUMN_DOCUMENT_ID, "$ROOT_ID/") 66 | .add(Root.COLUMN_AVAILABLE_BYTES, baseDirectory().freeSpace) 67 | .add(Root.COLUMN_TITLE, context().getString(R.string.app_name)) 68 | .add(Root.COLUMN_MIME_TYPES, "*/*") 69 | .add(Root.COLUMN_ICON, R.mipmap.ic_rpcs3) 70 | return cursor 71 | } 72 | 73 | override fun queryDocument(documentId: String, projection: Array?): Cursor { 74 | val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) 75 | includeFile(cursor, obtainFile(documentId)) 76 | return cursor 77 | } 78 | 79 | private fun includeFile(cursor: MatrixCursor, file: File) { 80 | val flags = when { 81 | file.isDirectory -> Document.FLAG_DIR_SUPPORTS_CREATE or Document.FLAG_SUPPORTS_REMOVE or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME 82 | else -> Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_REMOVE or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME 83 | } 84 | 85 | cursor.newRow() 86 | .add(Document.COLUMN_DOCUMENT_ID, obtainDocumentId(file)) 87 | .add(Document.COLUMN_MIME_TYPE, if (file.isDirectory) Document.MIME_TYPE_DIR else "application/octet-stream") 88 | .add(Document.COLUMN_FLAGS, flags) 89 | .add(Document.COLUMN_LAST_MODIFIED, file.lastModified()) 90 | .add(Document.COLUMN_DISPLAY_NAME, file.name) 91 | .add(Document.COLUMN_SIZE, file.length()) 92 | } 93 | 94 | override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { 95 | val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) 96 | obtainFile(parentDocumentId).listFiles()?.forEach { includeFile(cursor, it) } 97 | return cursor 98 | } 99 | 100 | override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { 101 | val parent = obtainFile(parentDocumentId) 102 | val file = File(parent, displayName) 103 | 104 | if (!parent.exists()) throw FileNotFoundException("Parent doesn't exist") 105 | 106 | if (mimeType == Document.MIME_TYPE_DIR) { 107 | if (!file.mkdirs()) throw FileNotFoundException("Error while creating directory") 108 | } else { 109 | if (!file.createNewFile()) throw FileNotFoundException("Error while creating file") 110 | } 111 | 112 | return obtainDocumentId(file) 113 | } 114 | 115 | override fun deleteDocument(documentId: String) { 116 | val file = obtainFile(documentId) 117 | if (file.exists()) { 118 | file.deleteRecursively() 119 | } else { 120 | throw FileNotFoundException("File not exists") 121 | } 122 | } 123 | 124 | override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor { 125 | return ParcelFileDescriptor.open(obtainFile(documentId), ParcelFileDescriptor.parseMode(mode)) 126 | } 127 | 128 | override fun removeDocument(documentId: String, parentDocumentId: String) { 129 | deleteDocument(documentId) 130 | } 131 | 132 | override fun renameDocument(documentId: String?, displayName: String?): String { 133 | if (documentId == null || displayName == null) { 134 | throw IllegalArgumentException("Document ID and display name must not be null") 135 | } 136 | 137 | val file = obtainFile(documentId) 138 | if (!file.exists()) { 139 | throw FileNotFoundException("File not found: $documentId") 140 | } 141 | 142 | val parentDir = file.parentFile 143 | val newFile = File(parentDir, displayName) 144 | 145 | if (newFile.exists()) { 146 | throw FileAlreadyExistsException(newFile) 147 | } 148 | 149 | if (!file.renameTo(newFile)) { 150 | throw IOException("Failed to rename file: ${file.absolutePath} to ${newFile.absolutePath}") 151 | } 152 | 153 | return obtainDocumentId(newFile) 154 | } 155 | 156 | override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { 157 | return documentId.startsWith(parentDocumentId) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/common/Previews.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.common 2 | 3 | import androidx.compose.material3.Surface 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import net.rpcs3.RPCS3Theme 7 | 8 | 9 | /** 10 | * a composable function for previewing UI elements within the application's theme. 11 | * 12 | * this functions wraps the provided content within a Material3 `Surface` and applies the application's 13 | * theme for consistent M3 previews, otherwise, the preview defaults to using the system theme. 14 | * 15 | * @param modifier The modifier to be applied to the `Surface`. 16 | * @param content The content to be previewed. 17 | * 18 | * @see AppTheme 19 | */ 20 | 21 | @Composable 22 | fun ComposePreview( 23 | modifier: Modifier = Modifier, 24 | content: @Composable () -> Unit 25 | ) { 26 | RPCS3Theme { 27 | Surface( 28 | modifier = modifier 29 | ) { 30 | content() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/CompositionLocals.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | 5 | 6 | /** 7 | * Created using Android Studio 8 | * User: Muhammad Ashhal 9 | * Date: Wed, Mar 05, 2025 10 | * Time: 1:00 am 11 | */ 12 | 13 | val LocalPreferenceState = compositionLocalOf { true } -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/base/BaseDialogPreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.base 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material3.AlertDialogDefaults 12 | import androidx.compose.material3.BasicAlertDialog 13 | import androidx.compose.material3.ExperimentalMaterial3Api 14 | import androidx.compose.material3.LocalContentColor 15 | import androidx.compose.material3.LocalTextStyle 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.Surface 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.Shape 24 | import androidx.compose.ui.text.TextStyle 25 | import androidx.compose.ui.unit.Dp 26 | import androidx.compose.ui.unit.dp 27 | import androidx.compose.ui.window.DialogProperties 28 | 29 | @OptIn(ExperimentalMaterial3Api::class) 30 | @Composable 31 | fun BaseDialogPreference( 32 | onDismissRequest: () -> Unit, 33 | modifier: Modifier = Modifier, 34 | icon: @Composable (() -> Unit)? = null, 35 | title: @Composable (() -> Unit)? = null, 36 | content: @Composable (() -> Unit)? = null, 37 | shape: Shape = AlertDialogDefaults.shape, 38 | containerColor: Color = AlertDialogDefaults.containerColor, 39 | iconContentColor: Color = AlertDialogDefaults.iconContentColor, 40 | titleContentColor: Color = AlertDialogDefaults.titleContentColor, 41 | contentColor: Color = AlertDialogDefaults.textContentColor, 42 | tonalElevation: Dp = AlertDialogDefaults.TonalElevation, 43 | properties: DialogProperties = DialogProperties() 44 | ) = BasicAlertDialog( 45 | onDismissRequest = onDismissRequest, 46 | modifier = modifier, 47 | properties = properties 48 | ) { 49 | DialogContent( 50 | icon = icon, 51 | title = title, 52 | content = content, 53 | shape = shape, 54 | containerColor = containerColor, 55 | tonalElevation = tonalElevation, 56 | iconContentColor = iconContentColor, 57 | titleContentColor = titleContentColor, 58 | contentColor = contentColor 59 | ) 60 | } 61 | 62 | @Composable 63 | private fun DialogContent( 64 | modifier: Modifier = Modifier, 65 | icon: (@Composable () -> Unit)?, 66 | title: (@Composable () -> Unit)?, 67 | content: @Composable (() -> Unit)?, 68 | shape: Shape, 69 | containerColor: Color, 70 | tonalElevation: Dp, 71 | iconContentColor: Color, 72 | titleContentColor: Color, 73 | contentColor: Color, 74 | ) { 75 | Surface( 76 | modifier = modifier, 77 | shape = shape, 78 | color = containerColor, 79 | tonalElevation = tonalElevation 80 | ) { 81 | Column( 82 | modifier = Modifier.padding(DialogPadding) 83 | ) { 84 | Row( 85 | modifier = Modifier.fillMaxWidth(), 86 | horizontalArrangement = Arrangement.Center, 87 | verticalAlignment = Alignment.CenterVertically 88 | ) { 89 | icon?.let { 90 | CompositionLocalProvider(value = LocalContentColor provides iconContentColor) { 91 | Box( 92 | modifier = Modifier 93 | .size(DialogIconSize) 94 | .align(Alignment.CenterVertically) 95 | ) { 96 | icon() 97 | } 98 | } 99 | } 100 | title?.let { 101 | ProvideContentColorTextStyle( 102 | contentColor = titleContentColor, 103 | textStyle = MaterialTheme.typography.titleLarge 104 | ) { 105 | Box( 106 | modifier = Modifier 107 | .padding(DialogTitlePadding) 108 | .align(Alignment.CenterVertically) 109 | ) { title() } 110 | } 111 | } 112 | } 113 | 114 | 115 | content?.let { 116 | val textStyle = MaterialTheme.typography.bodyLarge 117 | ProvideContentColorTextStyle( 118 | contentColor = contentColor, 119 | textStyle = textStyle 120 | ) { 121 | Box( 122 | Modifier 123 | .weight(weight = 1f, fill = false) 124 | .padding(DialogContentPadding) 125 | .align(Alignment.Start) 126 | ) { 127 | content() 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | 136 | @Composable 137 | fun ProvideContentColorTextStyle( 138 | contentColor: Color, 139 | textStyle: TextStyle, 140 | content: @Composable () -> Unit 141 | ) { 142 | val mergedStyle = LocalTextStyle.current.merge(textStyle) 143 | CompositionLocalProvider( 144 | LocalContentColor provides contentColor, 145 | LocalTextStyle provides mergedStyle, 146 | content = content 147 | ) 148 | } 149 | 150 | 151 | private val DialogPadding = PaddingValues(all = 16.dp) 152 | private val DialogIconSize = 36.dp 153 | private val DialogTitlePadding = PaddingValues(bottom = 8.dp) 154 | private val DialogContentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 12.dp) -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/base/BasePreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.base 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.combinedClickable 9 | import androidx.compose.foundation.ExperimentalFoundationApi 10 | import androidx.compose.foundation.layout.heightIn 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 14 | import androidx.compose.material.icons.filled.Search 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Surface 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.CompositionLocalProvider 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Shape 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.compose.ui.unit.Dp 24 | import androidx.compose.ui.unit.dp 25 | import net.rpcs3.ui.common.ComposePreview 26 | import net.rpcs3.ui.settings.components.LocalPreferenceState 27 | import net.rpcs3.ui.settings.components.core.PreferenceIcon 28 | import net.rpcs3.ui.settings.components.core.PreferenceSubtitle 29 | import net.rpcs3.ui.settings.components.core.PreferenceTitle 30 | 31 | 32 | /** 33 | * Created using Android Studio 34 | * User: Muhammad Ashhal 35 | * Date: Wed, Mar 05, 2025 36 | * Time: 1:03 am 37 | */ 38 | 39 | 40 | /** 41 | * A composable function that creates a base layout for a preference item. 42 | * 43 | * @param title title of the preference. 44 | * @param modifier The modifier applied to the preference container. 45 | * @param subContent Optional composable content to display below the title. 46 | * @param leadingContent Optional composable content to display at the start of the preference item. 47 | * This is typically used for icons or other visual cues. 48 | * 49 | * @param trailingContent Optional composable content to display at the end of the preference item. 50 | * This is typically used for switches, checkboxes, or other interactive elements. 51 | * 52 | * @param shape The shape of the preference surface. 53 | * @param tonalElevation The tonal elevation of the preference surface. 54 | * @param shadowElevation The shadow elevation of the preference surface. 55 | * @param enabled Whether the preference is enabled or disabled. 56 | * @param onClick callback invoked when the preference item is clicked. 57 | * 58 | * @see Surface 59 | */ 60 | 61 | @OptIn(ExperimentalFoundationApi::class) 62 | @Composable 63 | fun BasePreference( 64 | title: @Composable () -> Unit, 65 | modifier: Modifier = Modifier, 66 | subContent: @Composable (() -> Unit)? = null, 67 | value: @Composable (() -> Unit)? = null, 68 | leadingContent: @Composable (() -> Unit)? = null, 69 | trailingContent: @Composable (() -> Unit)? = null, 70 | shape: Shape = RoundedCornerShape(0), 71 | tonalElevation: Dp = 0.dp, 72 | shadowElevation: Dp = 0.dp, 73 | enabled: Boolean = true, 74 | onClick: () -> Unit, 75 | onLongClick: () -> Unit = {} 76 | ) { 77 | CompositionLocalProvider( 78 | LocalPreferenceState provides enabled 79 | ) { 80 | val preferenceOnClick: () -> Unit = { 81 | if (enabled) onClick() 82 | } 83 | Surface( 84 | modifier = modifier.combinedClickable( 85 | onClick = preferenceOnClick, 86 | onLongClick = onLongClick 87 | ), 88 | shape = shape, 89 | tonalElevation = tonalElevation, 90 | shadowElevation = shadowElevation 91 | ) { 92 | Row( 93 | modifier = Modifier 94 | .fillMaxWidth() 95 | .padding(horizontal = 16.dp) 96 | .heightIn(min = 72.dp), 97 | horizontalArrangement = Arrangement.spacedBy(16.dp), 98 | verticalAlignment = Alignment.CenterVertically 99 | ) { 100 | leadingContent?.invoke() 101 | Column( 102 | modifier = Modifier.weight(1f), 103 | verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), 104 | ) { 105 | title() 106 | subContent?.invoke() 107 | value?.invoke() 108 | } 109 | trailingContent?.invoke() 110 | } 111 | } 112 | } 113 | } 114 | 115 | @Preview 116 | @Composable 117 | private fun BasePreferencePreview() { 118 | ComposePreview { 119 | BasePreference( 120 | title = { PreferenceTitle("Preference Title") }, 121 | subContent = { PreferenceSubtitle("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ullamcorper tempor imperdiet. Tempor magna proident pariatur nonumy iusto, sint laborum possim accumsan, elit nonummy facer enim autem eiusmod lobortis reprehenderit molestie vel esse aliquyam cupiditat velit nisi aliquid ipsum. Erat accusam reprehenderit. Feugiat aliquyam iure. Nisi ex officia.", maxLines = 2) }, 122 | leadingContent = { PreferenceIcon(Icons.Default.Search) }, 123 | trailingContent = { PreferenceIcon(Icons.AutoMirrored.Default.KeyboardArrowRight) }, 124 | onClick = {} 125 | ) 126 | } 127 | } 128 | 129 | @Preview 130 | @Composable 131 | private fun BasePreferenceDisabledPreview() { 132 | ComposePreview { 133 | BasePreference( 134 | title = { PreferenceTitle("Preference Title") }, 135 | subContent = { PreferenceSubtitle("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ullamcorper tempor imperdiet. Tempor magna proident pariatur nonumy iusto, sint laborum possim accumsan, elit nonummy facer enim autem eiusmod lobortis reprehenderit molestie vel esse aliquyam cupiditat velit nisi aliquid ipsum. Erat accusam reprehenderit. Feugiat aliquyam iure. Nisi ex officia.", maxLines = 2) }, 136 | leadingContent = { PreferenceIcon(Icons.Default.Search) }, 137 | trailingContent = { PreferenceIcon(Icons.AutoMirrored.Default.KeyboardArrowRight) }, 138 | enabled = false, 139 | onClick = {} 140 | ) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/core/MaterialSwitch.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3.ui.settings.components.core 3 | 4 | import androidx.compose.animation.core.FastOutSlowInEasing 5 | import androidx.compose.animation.core.updateTransition 6 | import androidx.compose.animation.core.animateFloat 7 | import androidx.compose.animation.core.animateDp 8 | import androidx.compose.animation.animateColor 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.foundation.background 11 | import androidx.compose.foundation.clickable 12 | import androidx.compose.foundation.border 13 | import androidx.compose.foundation.layout.Box 14 | import androidx.compose.foundation.layout.offset 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.width 17 | import androidx.compose.foundation.layout.height 18 | import androidx.compose.foundation.shape.CircleShape 19 | import androidx.compose.foundation.shape.RoundedCornerShape 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.SwitchDefaults 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.clip 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.tooling.preview.Preview 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.draw.shadow 31 | 32 | @Composable 33 | fun MaterialSwitch( 34 | checked: Boolean = true, 35 | enabled: Boolean = true, 36 | onCheckedChange: (Boolean) -> Unit, 37 | modifier: Modifier = Modifier 38 | ) { 39 | val colors = SwitchDefaults.colors() 40 | val transition = updateTransition(targetState = checked, label = "switchTransition") 41 | 42 | val thumbOffset by transition.animateFloat( 43 | transitionSpec = { tween(300, easing = FastOutSlowInEasing) }, 44 | label = "thumbOffset" 45 | ) { if (it) 24f else 6f } 46 | 47 | val thumbSize by transition.animateDp( 48 | transitionSpec = { tween(300) }, 49 | label = "thumbSize" 50 | ) { if (it) 24.dp else 15.dp } 51 | 52 | val trackColor by transition.animateColor( 53 | transitionSpec = { tween(300) }, 54 | label = "trackColor" 55 | ) { if (it) if (enabled) colors.checkedTrackColor else colors.disabledCheckedTrackColor 56 | else if (enabled) colors.uncheckedTrackColor else colors.disabledUncheckedTrackColor 57 | } 58 | 59 | val thumbColor by transition.animateColor( 60 | transitionSpec = { tween(300) }, 61 | label = "thumbColor" 62 | ) { if (it) if (enabled) colors.checkedThumbColor else colors.disabledCheckedThumbColor 63 | else if (enabled) colors.uncheckedThumbColor else colors.disabledUncheckedThumbColor 64 | } 65 | 66 | val borderColor by transition.animateColor( 67 | transitionSpec = { tween(300) }, 68 | label = "borderColor" 69 | ) { if (it) if (enabled) colors.checkedBorderColor else colors.disabledCheckedBorderColor 70 | else if (enabled) colors.uncheckedBorderColor else colors.disabledUncheckedBorderColor 71 | } 72 | 73 | Box( 74 | modifier = modifier 75 | .width(52.dp) 76 | .height(32.dp) 77 | .clip(RoundedCornerShape(16.dp)) 78 | .border(if (!checked) 2.dp else 0.dp, borderColor, RoundedCornerShape(16.dp)) 79 | .background(trackColor) 80 | .clickable(enabled) { onCheckedChange(!checked) }, 81 | contentAlignment = Alignment.CenterStart 82 | ) { 83 | Box( 84 | modifier = Modifier 85 | .size(thumbSize) 86 | .offset(x = thumbOffset.dp) 87 | .shadow(4.dp, CircleShape) 88 | .clip(CircleShape) 89 | .background(thumbColor) 90 | ) 91 | } 92 | } 93 | 94 | @Preview 95 | @Composable 96 | fun MaterialSwitchPreview() { 97 | var switchState = true 98 | MaterialSwitch( 99 | checked = switchState, 100 | onCheckedChange = { switchState = it } 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/core/PreferenceIcon.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.core 2 | 3 | import androidx.compose.material3.Icon 4 | import androidx.compose.material3.LocalContentColor 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.painter.Painter 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | import androidx.compose.ui.unit.dp 11 | import net.rpcs3.ui.settings.components.LocalPreferenceState 12 | import net.rpcs3.ui.settings.util.preferenceColor 13 | import net.rpcs3.ui.settings.util.sizeIn 14 | 15 | /** 16 | * Created using Android Studio 17 | * User: Muhammad Ashhal 18 | * Date: Wed, Mar 05, 2025 19 | * Time: 1:32 am 20 | */ 21 | 22 | @Composable 23 | fun PreferenceIcon( 24 | icon: ImageVector?, 25 | modifier: Modifier = Modifier, 26 | enabled: Boolean = LocalPreferenceState.current, 27 | contentDescription: String? = null, 28 | tint: Color = preferenceColor(enabled, LocalContentColor.current), 29 | ) { 30 | if (icon != null) { 31 | Icon( 32 | imageVector = icon, 33 | modifier = modifier.sizeIn(minSize = 24.dp, maxSize = 48.dp), 34 | contentDescription = contentDescription, 35 | tint = tint 36 | ) 37 | } 38 | } 39 | 40 | @Composable 41 | fun PreferenceIcon( 42 | icon: Painter?, 43 | modifier: Modifier = Modifier, 44 | enabled: Boolean = LocalPreferenceState.current, 45 | contentDescription: String? = null, 46 | tint: Color = preferenceColor(enabled, LocalContentColor.current), 47 | ) { 48 | if (icon != null) { 49 | Icon( 50 | painter = icon, 51 | modifier = modifier.sizeIn(minSize = 24.dp, maxSize = 48.dp), 52 | contentDescription = contentDescription, 53 | tint = tint 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/core/PreferenceTitle.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.core 2 | 3 | import androidx.compose.material3.LocalContentColor 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.text.AnnotatedString 10 | import androidx.compose.ui.text.TextStyle 11 | import androidx.compose.ui.text.font.FontWeight 12 | import androidx.compose.ui.text.style.TextOverflow 13 | import androidx.compose.ui.unit.sp 14 | import net.rpcs3.ui.settings.components.LocalPreferenceState 15 | import net.rpcs3.ui.settings.util.preferenceColor 16 | import net.rpcs3.ui.settings.util.preferenceSubtitleColor 17 | 18 | /** 19 | * Created using Android Studio 20 | * User: Muhammad Ashhal 21 | * Date: Wed, Mar 05, 2025 22 | * Time: 1:32 am 23 | */ 24 | 25 | @Composable 26 | internal fun PreferenceTitle( 27 | title: String, 28 | modifier: Modifier = Modifier, 29 | enabled: Boolean = LocalPreferenceState.current, 30 | maxLines: Int = 1, 31 | overflow: TextOverflow = TextOverflow.Ellipsis, 32 | style: TextStyle = MaterialTheme.typography.headlineMedium.copy( 33 | fontSize = 17.sp, 34 | fontWeight = FontWeight.Normal, 35 | lineHeight = 22.sp 36 | ), 37 | color: Color = preferenceColor(enabled, LocalContentColor.current) 38 | ) { 39 | Text( 40 | text = title, 41 | modifier = modifier, 42 | style = style, 43 | maxLines = maxLines, 44 | overflow = overflow, 45 | color = color, 46 | ) 47 | } 48 | 49 | @Composable 50 | internal fun PreferenceValue( 51 | text: String, 52 | modifier: Modifier = Modifier, 53 | enabled: Boolean = LocalPreferenceState.current, 54 | maxLines: Int = 1, 55 | overflow: TextOverflow = TextOverflow.Ellipsis, 56 | style: TextStyle = MaterialTheme.typography.labelMedium.copy( 57 | fontSize = 13.sp, 58 | fontWeight = FontWeight.Bold, 59 | ), 60 | color: Color = preferenceColor(enabled, LocalContentColor.current) 61 | ) { 62 | Text( 63 | text = text, 64 | modifier = modifier, 65 | style = style, 66 | maxLines = maxLines, 67 | overflow = overflow, 68 | color = color, 69 | ) 70 | } 71 | 72 | @Composable 73 | internal fun PreferenceTitle( 74 | title: AnnotatedString, 75 | modifier: Modifier = Modifier, 76 | enabled: Boolean = LocalPreferenceState.current, 77 | maxLines: Int = 1, 78 | overflow: TextOverflow = TextOverflow.Ellipsis, 79 | style: TextStyle = MaterialTheme.typography.titleMedium, 80 | color: Color = preferenceColor(enabled, LocalContentColor.current) 81 | ) { 82 | Text( 83 | text = title, 84 | modifier = modifier, 85 | style = style, 86 | maxLines = maxLines, 87 | overflow = overflow, 88 | color = color, 89 | ) 90 | } 91 | 92 | @Composable 93 | fun PreferenceSubtitle( 94 | text: String, 95 | modifier: Modifier = Modifier, 96 | enabled: Boolean = LocalPreferenceState.current, 97 | maxLines: Int = 2, 98 | overflow: TextOverflow = TextOverflow.Ellipsis, 99 | style: TextStyle = MaterialTheme.typography.bodyMedium, 100 | color: Color = preferenceSubtitleColor(enabled, LocalContentColor.current), 101 | ) { 102 | Text( 103 | text = text, 104 | modifier = modifier, 105 | style = style, 106 | maxLines = maxLines, 107 | overflow = overflow, 108 | color = color, 109 | ) 110 | } 111 | 112 | @Composable 113 | fun PreferenceSubtitle( 114 | text: AnnotatedString, 115 | modifier: Modifier = Modifier, 116 | enabled: Boolean = LocalPreferenceState.current, 117 | maxLines: Int = 2, 118 | overflow: TextOverflow = TextOverflow.Ellipsis, 119 | style: TextStyle = MaterialTheme.typography.bodyMedium, 120 | color: Color = preferenceSubtitleColor(enabled, LocalContentColor.current), 121 | ) { 122 | Text( 123 | text = text, 124 | modifier = modifier, 125 | style = style, 126 | maxLines = maxLines, 127 | overflow = overflow, 128 | color = color, 129 | ) 130 | } 131 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/preference/CheckboxPreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.preference 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Build 5 | import androidx.compose.material3.Checkbox 6 | import androidx.compose.material3.CheckboxColors 7 | import androidx.compose.material3.CheckboxDefaults 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.tooling.preview.PreviewLightDark 16 | import net.rpcs3.ui.common.ComposePreview 17 | import net.rpcs3.ui.settings.components.core.PreferenceIcon 18 | import net.rpcs3.ui.settings.components.core.PreferenceSubtitle 19 | import net.rpcs3.ui.settings.components.core.PreferenceTitle 20 | 21 | 22 | /** 23 | * Created using Android Studio 24 | * User: Muhammad Ashhal 25 | * Date: Sat, Mar 08, 2025 26 | * Time: 7:26 pm 27 | */ 28 | 29 | @Composable 30 | fun CheckboxPreference( 31 | checked: Boolean, 32 | title: @Composable () -> Unit, 33 | leadingIcon: @Composable () -> Unit, 34 | modifier: Modifier = Modifier, 35 | subtitle: @Composable (() -> Unit)? = null, 36 | enabled: Boolean = true, 37 | checkboxColors: CheckboxColors = CheckboxDefaults.colors(), 38 | onClick: (Boolean) -> Unit 39 | ) { 40 | val onValueUpdated: (Boolean) -> Unit = { newValue -> onClick(newValue) } 41 | RegularPreference( 42 | title = title, 43 | leadingIcon = leadingIcon, 44 | modifier = modifier, 45 | subtitle = subtitle, 46 | enabled = enabled, 47 | trailingContent = { 48 | Checkbox( 49 | checked = checked, 50 | onCheckedChange = { onValueUpdated(it) }, 51 | enabled = enabled, 52 | colors = checkboxColors, 53 | ) 54 | }, 55 | onClick = { onValueUpdated(!checked) } 56 | ) 57 | } 58 | 59 | @Composable 60 | fun CheckboxPreference( 61 | checked: Boolean, 62 | title: String, 63 | leadingIcon: ImageVector, 64 | modifier: Modifier = Modifier, 65 | subtitle: @Composable (() -> Unit)? = null, 66 | enabled: Boolean = true, 67 | checkboxColors: CheckboxColors = CheckboxDefaults.colors(), 68 | onClick: (Boolean) -> Unit 69 | ) { 70 | CheckboxPreference( 71 | checked = checked, 72 | title = { PreferenceTitle(title = title) }, 73 | leadingIcon = { PreferenceIcon(icon = leadingIcon) }, 74 | modifier = modifier, 75 | subtitle = subtitle, 76 | enabled = enabled, 77 | checkboxColors = checkboxColors, 78 | onClick = onClick 79 | ) 80 | } 81 | 82 | @PreviewLightDark 83 | @Composable 84 | private fun CheckboxPreferencePreview() { 85 | ComposePreview { 86 | var isChecked by remember { mutableStateOf(true) } 87 | CheckboxPreference( 88 | checked = isChecked, 89 | title = "Enable Something", 90 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") }, 91 | leadingIcon = Icons.Default.Build 92 | ) { isChecked = it } 93 | } 94 | } 95 | 96 | @PreviewLightDark 97 | @Composable 98 | private fun CheckboxPreferenceDisabledPreview() { 99 | ComposePreview { 100 | var isChecked by remember { mutableStateOf(false) } 101 | CheckboxPreference( 102 | checked = isChecked, 103 | title = "Enable Something", 104 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") }, 105 | leadingIcon = Icons.Default.Build, 106 | enabled = false 107 | ) { isChecked = it } 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/preference/HomePreference.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3.ui.settings.components.preference 3 | 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.vector.ImageVector 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import androidx.compose.ui.text.font.FontWeight 14 | 15 | @Composable 16 | fun HomePreference( 17 | icon: @Composable (() -> Unit) = {}, 18 | title: String, 19 | description: String, 20 | onClick: () -> Unit 21 | ) { 22 | Card( 23 | modifier = Modifier 24 | .fillMaxWidth() 25 | .padding(horizontal = 12.dp) 26 | .padding(bottom = 24.dp) 27 | .clickable { onClick() }, 28 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), 29 | shape = MaterialTheme.shapes.medium 30 | ) { 31 | Row( 32 | modifier = Modifier 33 | .fillMaxWidth() 34 | .padding(vertical = 10.dp, horizontal = 20.dp), 35 | verticalAlignment = Alignment.CenterVertically 36 | ) { 37 | icon.invoke() 38 | 39 | Column( 40 | modifier = Modifier 41 | .weight(1f) 42 | .padding(start = 16.dp) 43 | ) { 44 | Text( 45 | text = title, 46 | style = MaterialTheme.typography.bodyMedium, 47 | fontWeight = FontWeight.Bold, 48 | fontSize = 16.sp 49 | ) 50 | 51 | Text( 52 | text = description, 53 | style = MaterialTheme.typography.bodySmall, 54 | fontSize = 14.sp, 55 | modifier = Modifier.padding(top = 5.dp) 56 | ) 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/preference/RegularPreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.preference 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 5 | import androidx.compose.material.icons.filled.Settings 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | import androidx.compose.ui.tooling.preview.PreviewLightDark 10 | import net.rpcs3.ui.common.ComposePreview 11 | import net.rpcs3.ui.settings.components.base.BasePreference 12 | import net.rpcs3.ui.settings.components.core.PreferenceIcon 13 | import net.rpcs3.ui.settings.components.core.PreferenceSubtitle 14 | import net.rpcs3.ui.settings.components.core.PreferenceTitle 15 | 16 | 17 | /** 18 | * Created using Android Studio 19 | * User: Muhammad Ashhal 20 | * Date: Wed, Mar 05, 2025 21 | * Time: 1:01 am 22 | */ 23 | 24 | /** 25 | * A regular preference item. 26 | * This is a simple preference item with a title, subtitle, leading icon, and trailing content. 27 | * This can also be called a simple TextPreference. 28 | * which is just a preference item, meant to show something to the user. 29 | * or be used to navigate the user to another screen. 30 | */ 31 | 32 | @Composable 33 | fun RegularPreference( 34 | title: @Composable () -> Unit, 35 | leadingIcon: @Composable (() -> Unit) = {}, 36 | modifier: Modifier = Modifier, 37 | subtitle: @Composable (() -> Unit)? = null, 38 | value: @Composable (() -> Unit)? = null, 39 | trailingContent: @Composable (() -> Unit)? = null, 40 | enabled: Boolean = true, 41 | onClick: () -> Unit, 42 | onLongClick: () -> Unit = {} 43 | ) { 44 | BasePreference( 45 | title = title, 46 | modifier = modifier, 47 | subContent = subtitle, 48 | value = value, 49 | leadingContent = leadingIcon, 50 | trailingContent = trailingContent, 51 | enabled = enabled, 52 | onClick = onClick, 53 | onLongClick = onLongClick 54 | ) 55 | } 56 | 57 | @Composable 58 | fun RegularPreference( 59 | title: String, 60 | leadingIcon: ImageVector? = null, 61 | modifier: Modifier = Modifier, 62 | subtitle: @Composable (() -> Unit)? = null, 63 | value: @Composable (() -> Unit)? = null, 64 | trailingContent: @Composable (() -> Unit)? = null, 65 | enabled: Boolean = true, 66 | onClick: () -> Unit, 67 | onLongClick: () -> Unit = {} 68 | ) { 69 | RegularPreference( 70 | title = { PreferenceTitle(title = title) }, 71 | leadingIcon = { PreferenceIcon(icon = leadingIcon) }, 72 | modifier = modifier, 73 | subtitle = subtitle, 74 | value = value, 75 | trailingContent = trailingContent, 76 | enabled = enabled, 77 | onClick = onClick, 78 | onLongClick = onLongClick 79 | ) 80 | } 81 | 82 | @PreviewLightDark 83 | @Composable 84 | private fun RegularPreferencePreview() { 85 | ComposePreview { 86 | RegularPreference( 87 | title = "Install Firmware", 88 | leadingIcon = Icons.Default.Settings, 89 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ullamcorper tempor imperdiet. Tempor magna proident pariatur nonumy iusto, sint laborum possim accumsan, elit nonummy facer enim autem eiusmod lobortis reprehenderit molestie vel esse aliquyam cupiditat velit nisi aliquid ipsum. Erat accusam reprehenderit. Feugiat aliquyam iure. Nisi ex officia.") }, 90 | trailingContent = { PreferenceIcon(icon = Icons.AutoMirrored.Default.KeyboardArrowRight) }, 91 | onClick = { } 92 | ) 93 | } 94 | } 95 | 96 | @PreviewLightDark 97 | @Composable 98 | private fun RegularPreferenceDisabledPreview() { 99 | ComposePreview { 100 | RegularPreference( 101 | title = "Advanced Settings", 102 | leadingIcon = Icons.Default.Settings, 103 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ullamcorper tempor imperdiet. Tempor magna proident pariatur nonumy iusto, sint laborum possim accumsan, elit nonummy facer enim autem eiusmod lobortis reprehenderit molestie vel esse aliquyam cupiditat velit nisi aliquid ipsum. Erat accusam reprehenderit. Feugiat aliquyam iure. Nisi ex officia.") }, 104 | trailingContent = { PreferenceIcon(icon = Icons.AutoMirrored.Default.KeyboardArrowRight) }, 105 | enabled = false, 106 | onClick = { } 107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/preference/SliderPreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.preference 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Refresh 8 | import androidx.compose.material3.AlertDialog 9 | import androidx.compose.material3.OutlinedTextField 10 | import androidx.compose.material3.Slider 11 | import androidx.compose.material3.SliderColors 12 | import androidx.compose.material3.SliderDefaults 13 | import androidx.compose.material3.Text 14 | import androidx.compose.material3.TextButton 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableFloatStateOf 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.vector.ImageVector 23 | import androidx.compose.ui.text.input.KeyboardType 24 | import androidx.compose.ui.tooling.preview.PreviewLightDark 25 | import androidx.compose.ui.unit.dp 26 | import net.rpcs3.ui.common.ComposePreview 27 | import net.rpcs3.ui.settings.components.core.PreferenceIcon 28 | import net.rpcs3.ui.settings.components.core.PreferenceSubtitle 29 | import net.rpcs3.ui.settings.components.core.PreferenceTitle 30 | 31 | 32 | /** 33 | * Created using Android Studio 34 | * User: Muhammad Ashhal 35 | * Date: Sat, Mar 08, 2025 36 | * Time: 10:35 pm 37 | */ 38 | 39 | @Composable 40 | fun SliderPreference( 41 | value: Float, 42 | onValueChange: (Float) -> Unit, 43 | title: String, 44 | modifier: Modifier = Modifier, 45 | leadingIcon: ImageVector? = null, 46 | subtitle: String? = null, 47 | enabled: Boolean = true, 48 | valueRange: ClosedFloatingPointRange = 0f..1f, 49 | steps: Int = 0, 50 | valueContent: @Composable (() -> Unit)? = null, 51 | sliderColors: SliderColors = SliderDefaults.colors(), 52 | onLongClick: () -> Unit = {} 53 | ) { 54 | var showDialog by remember { mutableStateOf(false) } 55 | var tempValue by remember { mutableFloatStateOf(value) } 56 | var textValue by remember { mutableStateOf(value.toInt().toString()) } 57 | var isError by remember { mutableStateOf(false) } 58 | 59 | val stepSize = 1 60 | 61 | fun isStepNotAligned(input: Float): Boolean { 62 | return input % stepSize != 0f 63 | } 64 | 65 | RegularPreference( 66 | modifier = modifier, 67 | title = { PreferenceTitle(title = title) }, 68 | leadingIcon = { PreferenceIcon(icon = leadingIcon) }, 69 | subtitle = { subtitle?.let { PreferenceSubtitle(text = it) } }, 70 | enabled = enabled, 71 | onClick = { showDialog = true }, 72 | value = valueContent, 73 | onLongClick = onLongClick 74 | ) 75 | 76 | if (showDialog) { 77 | AlertDialog( 78 | onDismissRequest = { showDialog = false }, 79 | title = { Text(title) }, 80 | text = { 81 | Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { 82 | OutlinedTextField( 83 | value = textValue, 84 | onValueChange = { input -> 85 | textValue = input 86 | val parsedValue = input.toFloatOrNull() 87 | if (parsedValue != null && parsedValue in valueRange) { 88 | isError = isStepNotAligned(parsedValue) 89 | if (!isError) tempValue = parsedValue 90 | } else { 91 | isError = true 92 | } 93 | }, 94 | keyboardOptions = KeyboardOptions.Default.copy( 95 | keyboardType = KeyboardType.Number 96 | ), 97 | isError = isError, 98 | label = { Text("Value") }, 99 | supportingText = { 100 | if (isError) { 101 | Text("Value must be a multiple of step size $stepSize within ${valueRange.start} to ${valueRange.endInclusive}") 102 | } 103 | } 104 | ) 105 | 106 | if ((valueRange.endInclusive - valueRange.start) < 1000) { 107 | Slider( 108 | value = tempValue, 109 | onValueChange = { newValue -> 110 | tempValue = newValue 111 | textValue = newValue.toInt().toString() 112 | }, 113 | valueRange = valueRange, 114 | steps = steps, 115 | colors = sliderColors 116 | ) 117 | } 118 | } 119 | }, 120 | confirmButton = { 121 | TextButton( 122 | onClick = { 123 | if (!isError) { 124 | onValueChange(tempValue) 125 | showDialog = false 126 | } 127 | }, 128 | enabled = !isError 129 | ) { 130 | Text("OK") 131 | } 132 | }, 133 | dismissButton = { 134 | TextButton(onClick = { 135 | showDialog = false 136 | tempValue = value 137 | textValue = value.toInt().toString() 138 | }) { 139 | Text("Cancel") 140 | } 141 | } 142 | ) 143 | } 144 | } 145 | 146 | @PreviewLightDark 147 | @Composable 148 | private fun SliderPreferencePreview() { 149 | ComposePreview { 150 | var value by remember { mutableFloatStateOf(0.5f) } 151 | SliderPreference( 152 | value = value, 153 | onValueChange = { value = it }, 154 | title = "Refresh Duration", 155 | leadingIcon = Icons.Default.Refresh, 156 | subtitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit." 157 | ) 158 | } 159 | } 160 | 161 | @PreviewLightDark 162 | @Composable 163 | private fun SliderPreferenceDisabledPreview() { 164 | ComposePreview { 165 | var value by remember { mutableFloatStateOf(0.5f) } 166 | SliderPreference( 167 | value = value, 168 | onValueChange = { value = it }, 169 | title = "Refresh Duration", 170 | leadingIcon = Icons.Default.Refresh, 171 | subtitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 172 | enabled = false 173 | ) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/components/preference/SwitchPreference.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.components.preference 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Build 5 | import net.rpcs3.ui.settings.components.core.MaterialSwitch 6 | import androidx.compose.material3.SwitchColors 7 | import androidx.compose.material3.SwitchDefaults 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.tooling.preview.PreviewLightDark 17 | import net.rpcs3.ui.common.ComposePreview 18 | import net.rpcs3.ui.settings.components.core.PreferenceIcon 19 | import net.rpcs3.ui.settings.components.core.PreferenceSubtitle 20 | import net.rpcs3.ui.settings.components.core.PreferenceTitle 21 | 22 | /** 23 | * Created using Android Studio 24 | * User: Muhammad Ashhal 25 | * Date: Sat, Mar 08, 2025 26 | * Time: 6:59 pm 27 | */ 28 | 29 | @Composable 30 | fun SwitchPreference( 31 | checked: Boolean, 32 | title: @Composable () -> Unit, 33 | leadingIcon: @Composable () -> Unit = {}, 34 | modifier: Modifier = Modifier, 35 | subtitle: @Composable (() -> Unit)? = null, 36 | enabled: Boolean = true, 37 | switchColors: SwitchColors = SwitchDefaults.colors(), 38 | onClick: (Boolean) -> Unit, 39 | onLongClick: () -> Unit = {} 40 | ) { 41 | val onValueUpdated: (Boolean) -> Unit = { newValue -> onClick(newValue) } 42 | RegularPreference( 43 | title = title, 44 | subtitle = subtitle, 45 | modifier = modifier, 46 | leadingIcon = leadingIcon, 47 | trailingContent = { 48 | MaterialSwitch( 49 | checked = checked, 50 | onCheckedChange = { onValueUpdated(it) }, 51 | enabled = enabled 52 | ) 53 | }, 54 | enabled = enabled, 55 | onClick = { onValueUpdated(!checked) }, 56 | onLongClick = onLongClick 57 | ) 58 | } 59 | 60 | @Composable 61 | fun SwitchPreference( 62 | checked: Boolean, 63 | title: String, 64 | leadingIcon: ImageVector? = null, 65 | modifier: Modifier = Modifier, 66 | subtitle: @Composable (() -> Unit)? = null, 67 | enabled: Boolean = true, 68 | switchColors: SwitchColors = SwitchDefaults.colors(), 69 | onClick: (Boolean) -> Unit, 70 | onLongClick: () -> Unit = {} 71 | ) { 72 | SwitchPreference( 73 | checked = checked, 74 | title = { PreferenceTitle(title = title) }, 75 | leadingIcon = { PreferenceIcon(icon = leadingIcon) }, 76 | modifier = modifier, 77 | subtitle = subtitle, 78 | enabled = enabled, 79 | switchColors = switchColors, 80 | onClick = onClick, 81 | onLongClick = onLongClick 82 | ) 83 | } 84 | 85 | @PreviewLightDark 86 | @Composable 87 | private fun SwitchPreview() { 88 | ComposePreview { 89 | var switchState by remember { mutableStateOf(true) } 90 | SwitchPreference( 91 | checked = switchState, 92 | title = "Enable Something", 93 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") }, 94 | leadingIcon = Icons.Default.Build, 95 | onClick = { 96 | switchState = it 97 | } 98 | ) 99 | } 100 | } 101 | 102 | @PreviewLightDark 103 | @Composable 104 | private fun SwitchDisabledPreview() { 105 | ComposePreview { 106 | var switchState by remember { mutableStateOf(true) } 107 | SwitchPreference( 108 | checked = switchState, 109 | title = "Enable Something", 110 | subtitle = { PreferenceSubtitle(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") }, 111 | leadingIcon = Icons.Default.Build, 112 | enabled = false, 113 | onClick = { 114 | switchState = it 115 | } 116 | ) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/util/ModifierExt.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.util 2 | 3 | import androidx.compose.foundation.layout.sizeIn 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.unit.Dp 6 | 7 | /** 8 | * Created using Android Studio 9 | * User: Muhammad Ashhal 10 | * Date: Wed, Mar 05, 2025 11 | * Time: 1:34 am 12 | */ 13 | 14 | /** 15 | * Constrains the size of the element to be within given bounds. 16 | * 17 | * The element's size will be at least [minSize] and at most [maxSize] in both dimensions. 18 | * If only one constraint is specified, the other dimension will be determined by the content. 19 | * 20 | * @param minSize The minimum size of the layout element in both dimensions. 21 | * @param maxSize The maximum size of the layout element in both dimensions. 22 | */ 23 | fun Modifier.sizeIn( 24 | minSize: Dp = Dp.Unspecified, 25 | maxSize: Dp = Dp.Unspecified 26 | ) = this.sizeIn(minWidth = minSize, minHeight = minSize, maxWidth = maxSize, maxHeight = maxSize) -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/ui/settings/util/Theming.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.ui.settings.util 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | /** 6 | * Created using Android Studio 7 | * User: Muhammad Ashhal 8 | * Date: Wed, Mar 05, 2025 9 | * Time: 1:34 am 10 | */ 11 | 12 | /** 13 | * A low level of alpha used to represent disabled components, such as text in a disabled Button. 14 | */ 15 | internal const val DisabledAlpha = 0.38f 16 | 17 | internal const val MediumAlpha = 0.67f 18 | 19 | /** 20 | * Returns a color to be used for preference title or leading/trailing icons. 21 | * 22 | * If the preference is enabled, the provided `contentColor` is returned as is. 23 | * If the preference is disabled, the `contentColor` is returned with its alpha 24 | * adjusted to represent a disabled state. 25 | * 26 | * @param enabled Whether the preference is enabled. 27 | * @param contentColor The base color to be used for the preference. 28 | * @return The color to be used for the preference title or icons. 29 | */ 30 | fun preferenceColor(enabled: Boolean, contentColor: Color) = 31 | if (!enabled) contentColor.copy(alpha = DisabledAlpha) else contentColor 32 | 33 | /** 34 | * Returns a color to be used for preference subtitle. 35 | * 36 | * The returned color is based on the enabled state of the preference and the provided content color. 37 | * If the preference is disabled, the content color will be modified to have a lower alpha value 38 | * representing the disabled state. Otherwise, the content color will be modified to have a medium 39 | * alpha value. 40 | * 41 | * @param enabled Whether the preference is enabled. 42 | * @param contentColor The base content color of the preference subtitle. 43 | * @return The color to be used for the preference subtitle. 44 | */ 45 | fun preferenceSubtitleColor(enabled: Boolean, contentColor: Color) = 46 | if (!enabled) contentColor.copy(alpha = DisabledAlpha) else contentColor.copy(alpha = MediumAlpha) -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/utils/DriverPackageMetadata.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3.utils 3 | 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.SerializationException 6 | import kotlinx.serialization.json.Json 7 | import kotlinx.serialization.json.decodeFromJsonElement 8 | import kotlinx.serialization.json.intOrNull 9 | import kotlinx.serialization.json.jsonObject 10 | import kotlinx.serialization.json.jsonPrimitive 11 | import java.io.File 12 | 13 | /** 14 | @url: https://github.com/strato-emu/strato/blob/ae1566a48285816a87e81d4aeb40bd2f4e56e60b/app/src/main/java/org/stratoemu/strato/data/DriverPackageMetadata.kt#L13 15 | */ 16 | data class GpuDriverMetadata( 17 | val name : String, 18 | val author : String, 19 | val packageVersion : String, 20 | val vendor : String, 21 | val driverVersion : String, 22 | val minApi : Int, 23 | val description : String, 24 | val libraryName : String, 25 | ) { 26 | private constructor(metadataV1 : GpuDriverMetadataV1) : this( 27 | name = metadataV1.name, 28 | author = metadataV1.author, 29 | packageVersion = metadataV1.packageVersion, 30 | vendor = metadataV1.vendor, 31 | driverVersion = metadataV1.driverVersion, 32 | minApi = metadataV1.minApi, 33 | description = metadataV1.description, 34 | libraryName = metadataV1.libraryName, 35 | ) 36 | 37 | val label get() = if (packageVersion.isEmpty()) name else "$name-v$packageVersion" 38 | 39 | companion object { 40 | private const val SCHEMA_VERSION_V1 = 1 41 | 42 | fun deserialize(metadataFile : File) : GpuDriverMetadata { 43 | val metadataJson = Json.parseToJsonElement(metadataFile.readText()) 44 | 45 | return when (metadataJson.jsonObject["schemaVersion"]?.jsonPrimitive?.intOrNull) { 46 | SCHEMA_VERSION_V1 -> GpuDriverMetadata(Json.decodeFromJsonElement(metadataJson)) 47 | else -> throw SerializationException("Unsupported metadata version") 48 | } 49 | } 50 | } 51 | } 52 | 53 | @Serializable 54 | private data class GpuDriverMetadataV1( 55 | val schemaVersion : Int, 56 | val name : String, 57 | val author : String, 58 | val packageVersion : String, 59 | val vendor : String, 60 | val driverVersion : String, 61 | val minApi : Int, 62 | val description : String, 63 | val libraryName : String, 64 | ) 65 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/utils/DriversFetcher.kt: -------------------------------------------------------------------------------- 1 | package net.rpcs3.utils 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import android.util.Log 6 | import io.ktor.client.* 7 | import io.ktor.client.call.* 8 | import io.ktor.client.plugins.contentnegotiation.* 9 | import io.ktor.client.plugins.logging.* 10 | import io.ktor.client.request.* 11 | import io.ktor.client.statement.* 12 | import io.ktor.serialization.kotlinx.json.* 13 | import io.ktor.utils.io.* 14 | import io.ktor.http.HttpHeaders 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.withContext 17 | import kotlinx.serialization.Serializable 18 | import kotlinx.serialization.json.Json 19 | import java.io.OutputStream 20 | import java.io.FileOutputStream 21 | import java.io.File 22 | 23 | object DriversFetcher { 24 | private val httpClient = HttpClient { 25 | install(ContentNegotiation) { 26 | json(Json { ignoreUnknownKeys = true }) 27 | } 28 | install(Logging) { 29 | level = LogLevel.BODY 30 | } 31 | } 32 | 33 | @Serializable 34 | data class GitHubRelease( 35 | val name: String, 36 | val assets: List = emptyList() 37 | ) 38 | 39 | @Serializable 40 | data class Asset(val browser_download_url: String) 41 | 42 | suspend fun fetchReleases(repoUrl: String, bypassValidation: Boolean = false): FetchResultOutput { 43 | val repoPath = repoUrl.removePrefix("https://github.com/") 44 | val validationUrl = "https://api.github.com/repos/$repoPath/contents/.adrenoDrivers" 45 | val apiUrl = "https://api.github.com/repos/$repoPath/releases" 46 | 47 | return try { 48 | val response: HttpResponse = withContext(Dispatchers.IO) { 49 | httpClient.get(apiUrl) 50 | } 51 | 52 | if (response.status.value != 200) 53 | return FetchResultOutput(emptyList(), FetchResult.Error("Failed to fetch drivers")) 54 | 55 | val isValid = withContext(Dispatchers.IO) { 56 | try { 57 | httpClient.get(validationUrl).status.value == 200 58 | } catch (e: Exception) { 59 | false 60 | } 61 | } 62 | 63 | if (!isValid && !bypassValidation) { 64 | return FetchResultOutput(emptyList(), FetchResult.Warning("Provided driver repo url is not valid.")) 65 | } 66 | 67 | val releases: List = response.body() 68 | val drivers = releases.map { release -> 69 | val assetUrl = release.assets.firstOrNull()?.browser_download_url 70 | release.name to assetUrl 71 | } 72 | FetchResultOutput(drivers, FetchResult.Success) 73 | } catch (e: Exception) { 74 | Log.e("DriversFetcher", "Error fetching releases: ${e.message}", e) 75 | FetchResultOutput(emptyList(), FetchResult.Error("Error fetching releases: ${e.message}")) 76 | } 77 | } 78 | 79 | suspend fun downloadAsset( 80 | assetUrl: String, 81 | destinationFile: File, 82 | progressCallback: (Long, Long) -> Unit 83 | ): DownloadResult { 84 | return try { 85 | withContext(Dispatchers.IO) { 86 | val response: HttpResponse = httpClient.get(assetUrl) 87 | val contentLength = response.headers[HttpHeaders.ContentLength]?.toLong() ?: -1L 88 | 89 | FileOutputStream(destinationFile)?.use { outputStream -> 90 | writeResponseToStream(response, outputStream, contentLength, progressCallback) 91 | } ?: return@withContext DownloadResult.Error("Failed to open ${destinationFile.absolutePath}") 92 | } 93 | DownloadResult.Success 94 | } catch (e: Exception) { 95 | Log.e("DriversFetcher", "Error downloading file: ${e.message}", e) 96 | DownloadResult.Error(e.message) 97 | } 98 | } 99 | 100 | private suspend fun writeResponseToStream( 101 | response: HttpResponse, 102 | outputStream: OutputStream, 103 | contentLength: Long, 104 | progressCallback: (Long, Long) -> Unit 105 | ) { 106 | val channel = response.bodyAsChannel() 107 | val buffer = ByteArray(1024) // 1KB buffer size 108 | var totalBytesRead = 0L 109 | 110 | while (!channel.isClosedForRead) { 111 | val bytesRead = channel.readAvailable(buffer) 112 | if (bytesRead > 0) { 113 | outputStream.write(buffer, 0, bytesRead) 114 | totalBytesRead += bytesRead 115 | progressCallback(totalBytesRead, contentLength) 116 | } 117 | } 118 | outputStream.flush() 119 | } 120 | 121 | sealed class DownloadResult { 122 | object Success : DownloadResult() 123 | data class Error(val message: String?) : DownloadResult() 124 | } 125 | 126 | data class FetchResultOutput( 127 | val fetchedDrivers: List>, 128 | val result: FetchResult 129 | ) 130 | 131 | sealed class FetchResult { 132 | object Success : FetchResult() 133 | data class Error(val message: String?) : FetchResult() 134 | data class Warning(val message: String?) : FetchResult() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/src/main/java/net/rpcs3/utils/ZipUtil.kt: -------------------------------------------------------------------------------- 1 | 2 | package net.rpcs3.utils 3 | 4 | import java.io.* 5 | import java.util.zip.ZipEntry 6 | import java.util.zip.ZipFile 7 | import java.util.zip.ZipInputStream 8 | 9 | object ZipUtil { 10 | 11 | @Throws(IOException::class) 12 | fun unzip(file : File, targetDirectory : File) { 13 | ZipFile(file).use { zipFile -> 14 | for (zipEntry in zipFile.entries()) { 15 | val destFile = createNewFile(targetDirectory, zipEntry) 16 | // If the zip entry is a file, we need to create its parent directories 17 | val destDirectory : File? = if (zipEntry.isDirectory) destFile else destFile.parentFile 18 | 19 | // Create the destination directory 20 | if (destDirectory == null || (!destDirectory.isDirectory && !destDirectory.mkdirs())) 21 | throw FileNotFoundException("Failed to create destination directory: $destDirectory") 22 | 23 | // If the entry is a directory we don't need to copy anything 24 | if (zipEntry.isDirectory) 25 | continue 26 | 27 | // Copy bytes to destination 28 | try { 29 | zipFile.getInputStream(zipEntry).use { inputStream -> 30 | destFile.outputStream().use { outputStream -> 31 | inputStream.copyTo(outputStream) 32 | } 33 | } 34 | } catch (e : IOException) { 35 | if (destFile.exists()) 36 | destFile.delete() 37 | throw e 38 | } 39 | } 40 | } 41 | } 42 | 43 | @Throws(IOException::class) 44 | fun unzip(stream : InputStream, targetDirectory : File) { 45 | ZipInputStream(BufferedInputStream(stream)).use { zis -> 46 | do { 47 | // Get the next entry, break if we've reached the end 48 | val zipEntry = zis.nextEntry ?: break 49 | 50 | val destFile = createNewFile(targetDirectory, zipEntry) 51 | // If the zip entry is a file, we need to create its parent directories 52 | val destDirectory : File? = if (zipEntry.isDirectory) destFile else destFile.parentFile 53 | 54 | // Create the destination directory 55 | if (destDirectory == null || (!destDirectory.isDirectory && !destDirectory.mkdirs())) 56 | throw FileNotFoundException("Failed to create destination directory: $destDirectory") 57 | 58 | // If the entry is a directory we don't need to copy anything 59 | if (zipEntry.isDirectory) 60 | continue 61 | 62 | // Copy bytes to destination 63 | try { 64 | BufferedOutputStream(destFile.outputStream()).use { zis.copyTo(it) } 65 | } catch (e : IOException) { 66 | if (destFile.exists()) 67 | destFile.delete() 68 | throw e 69 | } 70 | } while (true) 71 | } 72 | } 73 | 74 | @Throws(IOException::class) 75 | private fun createNewFile(destinationDir : File, zipEntry : ZipEntry) : File { 76 | val destFile = File(destinationDir, zipEntry.name) 77 | val destDirPath = destinationDir.canonicalPath 78 | val destFilePath = destFile.canonicalPath 79 | 80 | if (!destFilePath.startsWith(destDirPath + File.separator)) 81 | throw IOException("Entry is outside of the target dir: " + zipEntry.name) 82 | 83 | return destFile 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/circle.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/cross.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/dpad_bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/dpad_bottom.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/dpad_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/dpad_left.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/dpad_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/dpad_right.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/dpad_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/dpad_top.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_description.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_folder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_grid_off.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_grid_on.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_osc_off.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_palette.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_restore.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_rpcs3_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 16 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_rpcs3_monochrome.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_show_osc.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_stop.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/l1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/l1.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/l2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/l2.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/l3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/l3.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/left_stick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/left_stick.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/left_stick_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/left_stick_background.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/r1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/r1.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/r2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/r2.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/r3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/r3.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/right_stick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/right_stick.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/right_stick_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/right_stick_background.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/select.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/square.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/start.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/triangle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RPCS3-Android/rpcs3-android/e2f87a7f14890ac6ba332343b3b811ca1f14e2d1/app/src/main/res/drawable/triangle.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 |