emu: complete gui overhaul

This commit is contained in:
KorewaWatchful
2026-05-11 13:47:44 -04:00
committed by Gamid
parent a01f2b15b6
commit 91f533f86f
567 changed files with 64356 additions and 40471 deletions
+78 -22
View File
@@ -51,13 +51,9 @@ jobs:
cmake_preset: windows-ninja
- os: windows-11-arm
arch: arm64
cache_path: |
C:\vcpkg\installed
C:\vcpkg\packages
vcpkg_triplet: arm64-windows-static-md
extra_cmake_args: -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache -DCMAKE_TOOLCHAIN_FILE=C:\vcpkg\scripts\buildsystems\vcpkg.cmake -DVCPKG_TARGET_TRIPLET=arm64-windows-static-md
cmake_preset: windows-ninja-clang
cache_path: C:\msys2-ccache
msys2_env: CLANGARM64
cmake_preset: windows-ninja
- os: macos-intel
runs_on: macos-latest
@@ -112,17 +108,22 @@ jobs:
curl -sLO https://github.com/linuxdeploy/linuxdeploy/releases/latest/download/linuxdeploy-${{ matrix.arch }}.AppImage
sudo cp -f linuxdeploy-${{ matrix.arch }}.AppImage /usr/local/bin/
sudo chmod +x /usr/local/bin/linuxdeploy-${{ matrix.arch }}.AppImage
# Set up linuxdeploy-plugin-qt for Qt bundling in AppImage
curl -sLO https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/latest/download/linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage
sudo cp -f linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage /usr/local/bin/
sudo chmod +x /usr/local/bin/linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage
if: startsWith(matrix.os, 'ubuntu')
- name: Set up build environment (Windows)
run: |
vcpkg install zlib boost-system boost-filesystem boost-program-options boost-icl boost-variant curl openssl --triplet=${{ matrix.vcpkg_triplet }}
if: startsWith(matrix.os, 'windows')
if: startsWith(matrix.os, 'windows') && !matrix.msys2_env
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: ${{ matrix.arch }}
if: startsWith(matrix.os, 'windows')
if: startsWith(matrix.os, 'windows') && !matrix.msys2_env
- name: Set up build environment (Android)
run: |
@@ -134,18 +135,39 @@ jobs:
if: matrix.os == 'android'
- uses: mozilla-actions/sccache-action@v0.0.10
if: startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'ubuntu')
if: (startsWith(matrix.os, 'windows') && !matrix.msys2_env) || startsWith(matrix.os, 'ubuntu')
with:
disable_annotations: true
- name: Set up MSYS2 (Windows ARM)
uses: msys2/setup-msys2@v2
with:
msystem: ${{ matrix.msys2_env }}
update: true
cache: true
install: |
git
pacboy: |
clang:p
ccache:p
cmake:p
lld:p
ninja:p
boost:p
openssl:p
curl:p
zlib:p
qt6-base:p
qt6-svg:p
qt6-tools:p
if: matrix.msys2_env
- name: Restore Gradle Cache
uses: actions/cache@v5
with:
path: '~/.gradle/'
key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-${{ hashFiles('android/**/*.xml') }}-${{ hashFiles('android/**.java') }}
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties', 'android/**/*.xml', 'android/**/*.java', 'android/**/*.kt') }}
restore-keys: |
${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-${{ hashFiles('android/**/*.xml') }}-
${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-
${{ runner.os }}-gradle-
if: matrix.os == 'android'
@@ -162,11 +184,42 @@ jobs:
run: ccache -z
if: startsWith(matrix.os, 'macos')
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.2'
cache: true
if: matrix.os != 'android' && !matrix.msys2_env
- name: Build Vita3K
run: |
cmake ${{ matrix.extra_cmake_args }} --preset ${{ matrix.cmake_preset }}
cmake --build build/${{ matrix.cmake_preset }} --config ${{ env.BUILD_CONFIG }}
if: matrix.os != 'android'
if: matrix.os != 'android' && !matrix.msys2_env
- name: Build Vita3K (MSYS2)
shell: msys2 {0}
run: |
export CCACHE_DIR=$(cygpath -u "C:/msys2-ccache")
cmake -G "Ninja Multi-Config" -B build/${{ matrix.cmake_preset }} \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build/${{ matrix.cmake_preset }} --config ${{ env.BUILD_CONFIG }}
if: matrix.msys2_env
- name: Deploy MSYS2 runtime libraries
shell: msys2 {0}
run: |
BINDIR="build/${{ matrix.cmake_preset }}/bin/${{ env.BUILD_CONFIG }}"
MSYS2_BINDIR="/${MSYSTEM,,}/bin"
for dll in libc++.dll libunwind.dll; do
if [ -f "$MSYS2_BINDIR/$dll" ]; then
cp "$MSYS2_BINDIR/$dll" "$BINDIR/"
fi
done
if: matrix.msys2_env
- name: Print ccache statistics
run: ccache -s
@@ -174,7 +227,7 @@ jobs:
- name: Print sccache statistics
run: sccache --show-stats
if: startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'ubuntu')
if: (startsWith(matrix.os, 'windows') && !matrix.msys2_env) || startsWith(matrix.os, 'ubuntu')
- name: Build Vita3K (Android)
env:
@@ -185,23 +238,26 @@ jobs:
JVM_OPTS: -Xmx6G
VCPKG_ROOT: /usr/local/share/vcpkg
run: |
cp -r data android/assets
cp -r lang android/assets
cp -r vita3k/shaders-builtin android/assets
export JAVA_HOME=$(echo $JAVA_HOME_17_X64)
mkdir -p android/app/assets
rm -rf android/app/assets/data android/app/assets/shaders-builtin
cp -r data android/app/assets/data
cp -r vita3k/shaders-builtin android/app/assets/shaders-builtin
export JAVA_HOME="$JAVA_HOME_17_X64"
chmod +x android/gradlew
if [ -e "$SIGNING_STORE_PATH" ]; then
BUILD_TYPE=Release
else
BUILD_TYPE=Reldebug
fi
./gradlew --stacktrace --configuration-cache --build-cache --parallel --configure-on-demand assemble${BUILD_TYPE}
echo "APK_PATH=android/build/outputs/apk/${BUILD_TYPE,,}/android-${BUILD_TYPE,,}.apk" >> $GITHUB_ENV
cd android
./gradlew --stacktrace --configuration-cache --build-cache --parallel --configure-on-demand :app:assemble${BUILD_TYPE}
echo "APK_PATH=android/app/build/outputs/apk/${BUILD_TYPE,,}/app-${BUILD_TYPE,,}.apk" >> $GITHUB_ENV
if: matrix.os == 'android'
- name: Run tests
working-directory: build/${{ matrix.cmake_preset }}
run: ctest --build-config ${{ env.BUILD_CONFIG }} --output-on-failure
if: matrix.os != 'android'
if: matrix.os != 'android' && !matrix.msys2_env
- name: Compute git short SHA
shell: bash
+7
View File
@@ -56,6 +56,13 @@ jobs:
with:
disable_annotations: true
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.2'
cache: true
install-deps: 'true'
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
+3 -3
View File
@@ -53,6 +53,9 @@ proguard/
# Android Studio captures folder
captures/
# Kotlin
.kotlin/
# IntelliJ
*.iml
.idea/
@@ -64,9 +67,6 @@ captures/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# CXX compile cache
android/.cxx
# Google Services (e.g. APIs or Firebase)
google-services.json
-6
View File
@@ -23,12 +23,6 @@
[submodule "external/googletest"]
path = external/googletest
url = https://github.com/google/googletest
[submodule "external/imgui"]
path = external/imgui
url = https://github.com/ocornut/imgui
[submodule "external/imgui_club"]
path = external/imgui_club
url = https://github.com/ocornut/imgui_club
[submodule "external/libfat16"]
path = external/libfat16
url = https://github.com/Vita3K/libfat16
-1
View File
@@ -51,7 +51,6 @@ else()
option(BUILD_APPIMAGE "Build an AppImage." OFF)
endif()
option(USE_VITA3K_UPDATE "Build Vita3K with updater." ON)
option(FORCE_BUILD_OPENSSL_MAC OFF)
if("${CMAKE_CXX_COMPILER_LAUNCHER}" STREQUAL "")
+25
View File
@@ -0,0 +1,25 @@
# Build artifacts
.gradle/
build/
bin/
gen/
out/
.externalNativeBuild/
.cxx/
.kotlin/
# Local configuration
local.properties
# IDE specific
.idea/
*.iml
*.ipr
*.iws
.navigation/
captures/
.cache/
# System files
.DS_Store
Thumbs.db
+139
View File
@@ -0,0 +1,139 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
def signingStorePath = System.getenv("SIGNING_STORE_PATH")
def signingStorePassword = System.getenv("SIGNING_STORE_PASSWORD")
def signingKeyAlias = System.getenv("SIGNING_KEY_ALIAS")
def signingKeyPassword = System.getenv("SIGNING_KEY_PASSWORD")
def hasCiSigning = signingStorePath && signingStorePassword && signingKeyAlias && signingKeyPassword
android {
namespace "org.vita3k.emulator"
compileSdk 35
ndkVersion "27.3.13750724"
buildFeatures {
buildConfig true
compose true
}
defaultConfig {
applicationId "org.vita3k.emulator"
minSdk 28
targetSdk 35
versionCode 21
versionName "0.2.1"
externalNativeBuild {
cmake {
abiFilters.addAll( 'arm64-v8a' )
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" // required until NDK r28
}
}
}
signingConfigs {
ci {
if (hasCiSigning) {
storeFile file(signingStorePath)
storePassword signingStorePassword
keyAlias signingKeyAlias
keyPassword signingKeyPassword
}
}
}
buildTypes {
debug {
debuggable true
jniDebuggable true
applicationIdSuffix '.debug'
}
reldebug {
debuggable true
jniDebuggable true
externalNativeBuild {
cmake {
arguments "-DCMAKE_BUILD_TYPE=RelWithDebInfo"
}
}
minifyEnabled false
shrinkResources false
applicationIdSuffix '.debug'
signingConfig = signingConfigs.debug
}
release {
debuggable false
jniDebuggable false
externalNativeBuild {
cmake {
arguments "-DCMAKE_BUILD_TYPE=Release"
}
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig = hasCiSigning ? signingConfigs.ci : signingConfigs.debug
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main {
jniLibs.srcDirs = ['../prebuilt']
assets {
srcDirs 'assets'
}
java {
srcDirs += ['../../external/sdl/android-project/app/src/main/java']
}
}
}
externalNativeBuild {
cmake {
path '../../CMakeLists.txt'
version '3.22.1+'
}
}
lint {
abortOnError false
checkReleaseBuilds false
}
packagingOptions {
jniLibs {
useLegacyPackaging true
}
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.core:core:1.12.0"
implementation 'androidx.core:core-google-shortcuts:1.1.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.getkeepsafe.relinker:relinker:1.4.5'
implementation platform('androidx.compose:compose-bom:2025.04.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.activity:activity-compose:1.9.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation 'androidx.navigation:navigation-compose:2.8.5'
implementation 'io.coil-kt:coil-compose:2.7.0'
debugImplementation 'androidx.compose.ui:ui-tooling'
}
+11
View File
@@ -0,0 +1,11 @@
# this is necessary so that native functions can call Java ones using JNI
-keep class org.libsdl.app** { *; }
-keep class org.vita3k.emulator.NativeLib { *; }
-keep class org.vita3k.emulator.Emulator { *; }
-keep class org.vita3k.emulator.EmuSurface { *; }
-keep class org.vita3k.emulator.overlay.** { *; }
-keep class org.vita3k.emulator.data.EmulatorConfig { *; }
-keep class org.vita3k.emulator.data.NativeAppInfo { *; }
-keep class org.vita3k.emulator.data.NativeImeState { *; }
-keep class org.vita3k.emulator.data.NativeUser { *; }
-keep interface org.vita3k.emulator.data.InstallCallback { *; }
@@ -61,11 +61,6 @@
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
android:minSdkVersion="30" />
<!-- Allow the app to request installing APKs from unknown sources (introduced in Android 8 / API 26) -->
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
android:minSdkVersion="26"/>
<!-- Allow access to Bluetooth devices -->
<!-- Currently this is just for Steam Controller support and requires setting SDL_HINT_JOYSTICK_HIDAPI_STEAM -->
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> -->
@@ -73,6 +68,9 @@
<!-- Allow access to the vibrator -->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name = "android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name = "android.permission.ACCESS_NETWORK_STATE" />
@@ -90,7 +88,8 @@
The requestLegacyExternalStorage parameter only has an effect when running on Android <= 10
-->
<application android:label="@string/app_name"
<application android:name=".Vita3KApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
@@ -98,62 +97,44 @@
android:appCategory="game"
android:isGame="true"
android:hasFragileUserData="true"
android:localeConfig="@xml/locales_config"
android:requestLegacyExternalStorage="true"
tools:targetApi="q">
<!-- Example of setting SDL hints from AndroidManifest.xml:
<meta-data android:name="SDL_ENV.SDL_ACCELEROMETER_AS_JOYSTICK" android:value="0"/>
-->
<activity android:name="Emulator"
android:alwaysRetainTaskState="true"
android:launchMode="singleInstance"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:preferMinimalPostProcessing="true"
android:theme="@style/Theme.Design.NoActionBar"
<!-- Main Compose UI activity (launcher) -->
<activity android:name=".MainActivity"
android:theme="@style/Theme.Vita3K.Starting"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|layoutDirection|locale|keyboard|keyboardHidden"
tools:targetApi="r">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Let Android know that we can handle some USB devices and should receive this event -->
</activity>
<!-- SDL game rendering activity -->
<activity android:name=".Emulator"
android:alwaysRetainTaskState="true"
android:launchMode="singleTop"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:preferMinimalPostProcessing="true"
android:theme="@style/Theme.Design.NoActionBar"
android:exported="true"
tools:targetApi="r">
<!-- USB device handling -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<!-- Drop file event -->
<!--
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
-->
</activity>
<provider
android:name="org.vita3k.emulator.provider.VitaDocumentsProvider"
android:authorities="${applicationId}.provider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
<service
android:name=".InstallForegroundService"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
</meta-data>
</provider>
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -44,5 +44,4 @@ public class EmuSurface extends SDLSurface {
}
public native void setSurfaceStatus(boolean surface_present);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
package org.vita3k.emulator
import android.view.InputDevice
enum class ControllerFamily {
Standard,
Xbox,
Nintendo
}
data class ConnectedGamepad(
val deviceId: Int,
val name: String,
val family: ControllerFamily
)
object InputDeviceUtils {
@JvmStatic
fun hasPhysicalGamepadConnected(): Boolean {
return getPhysicalGamepads().isNotEmpty()
}
@JvmStatic
fun getPhysicalGamepads(): List<ConnectedGamepad> {
val controllers = mutableListOf<ConnectedGamepad>()
val deviceIds = InputDevice.getDeviceIds()
for (deviceId in deviceIds) {
val device = InputDevice.getDevice(deviceId) ?: continue
if (device.isVirtual) {
continue
}
val name = device.name.orEmpty()
if (name.startsWith("uinput-") || name.startsWith("gf_")) {
continue
}
val sources = device.sources
val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
if (isGamepad) {
controllers += ConnectedGamepad(
deviceId = deviceId,
name = name,
family = inferControllerFamily(name)
)
}
}
return controllers.sortedBy { it.deviceId }
}
private fun inferControllerFamily(name: String): ControllerFamily {
val normalized = name.lowercase()
return when {
normalized.contains("xbox") || normalized.contains("x-input") -> ControllerFamily.Xbox
normalized.contains("switch") || normalized.contains("joy-con") || normalized.contains("nintendo") -> ControllerFamily.Nintendo
else -> ControllerFamily.Standard
}
}
}
@@ -0,0 +1,371 @@
package org.vita3k.emulator
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.vita3k.emulator.data.InstallRepository
import org.vita3k.emulator.ui.viewmodel.DeleteSourceOption
import org.vita3k.emulator.ui.viewmodel.InstallResult
import org.vita3k.emulator.ui.viewmodel.InstallResultStatus
import java.io.File
class InstallForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var wakeLock: PowerManager.WakeLock? = null
private var activeOperationId: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val operationId = intent?.getLongExtra(InstallServiceController.EXTRA_OPERATION_ID, 0L) ?: 0L
val operationTypeName = intent?.getStringExtra(InstallServiceController.EXTRA_OPERATION_TYPE)
val operationType = operationTypeName?.let(InstallOperationType::valueOf)
if (operationId == 0L || operationType == null) {
stopSelfResult(startId)
return START_NOT_STICKY
}
if (activeOperationId != 0L && activeOperationId != operationId) {
stopSelfResult(startId)
return START_NOT_STICKY
}
activeOperationId = operationId
ensureNotificationChannel()
startForeground(
NOTIFICATION_ID,
buildNotification(
InstallServiceController.state.value.statusMessage.ifBlank {
getString(R.string.install_progress_title)
}
)
)
acquireWakeLock()
NativeLib.prepareFrontend()
serviceScope.launch {
val result = runOperation(operationId, operationType, intent)
InstallServiceController.finish(operationId, result)
updateNotification(result.message)
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
activeOperationId = 0L
}
return START_NOT_STICKY
}
override fun onDestroy() {
releaseWakeLock()
serviceScope.cancel()
super.onDestroy()
}
private suspend fun runOperation(
operationId: Long,
operationType: InstallOperationType,
intent: Intent
): InstallResult = try {
when (operationType) {
InstallOperationType.FIRMWARE -> installFirmware(
operationId = operationId,
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
)
InstallOperationType.PKG -> installPkg(
operationId = operationId,
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty(),
zrif = intent.getStringExtra(InstallServiceController.EXTRA_ZRIF).orEmpty(),
licensePath = intent.getStringExtra(InstallServiceController.EXTRA_LICENSE_PATH)
)
InstallOperationType.ARCHIVE -> installArchive(
operationId = operationId,
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
)
InstallOperationType.ARCHIVE_FOLDER -> installArchiveFolder(
operationId = operationId,
paths = intent.getStringArrayListExtra(InstallServiceController.EXTRA_PATHS).orEmpty()
)
InstallOperationType.LICENSE_FILE -> installLicense(
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
)
InstallOperationType.LICENSE_ZRIF -> installLicenseFromZrif(
zrif = intent.getStringExtra(InstallServiceController.EXTRA_ZRIF).orEmpty()
)
}
} catch (e: Exception) {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_error_generic, e.message ?: "")
)
}
private suspend fun installFirmware(operationId: Long, path: String): InstallResult {
val deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_firmware_file, path)
)
val version = InstallRepository.installFirmware(path) { pct, status ->
onProgress(operationId, pct, status)
}
return if (version.isNotEmpty()) {
InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_firmware, version),
deleteOptions = deleteOptions
)
} else {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_firmware),
deleteOptions = deleteOptions
)
}
}
private suspend fun installPkg(
operationId: Long,
path: String,
zrif: String,
licensePath: String?
): InstallResult {
val deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_package_file, path),
deleteOption(R.string.install_delete_license_file, licensePath)
)
val success = InstallRepository.installPkg(path, zrif) { pct, status ->
onProgress(operationId, pct, status)
}
return if (success) {
InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_package),
deleteOptions = deleteOptions
)
} else {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_package),
deleteOptions = deleteOptions
)
}
}
private suspend fun installArchive(operationId: Long, path: String): InstallResult {
val deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_archive_files, path)
)
val success = InstallRepository.installArchive(path, forceReinstall = true) { pct, status ->
onProgress(operationId, pct, status)
}
return if (success) {
InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_archive),
deleteOptions = deleteOptions
)
} else {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_archive),
deleteOptions = deleteOptions
)
}
}
private suspend fun installArchiveFolder(operationId: Long, paths: List<String>): InstallResult {
if (paths.isEmpty()) {
return InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_error_archive_folder_empty)
)
}
val deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_archive_files, paths)
)
val total = paths.size
var successCount = 0
paths.forEachIndexed { index, path ->
val archiveName = File(path).name.ifBlank { path }
val batchStatus = getString(
R.string.install_status_archive_batch,
index + 1,
total,
archiveName
)
InstallServiceController.updateProgress(operationId, ((index * 100f) / total).toInt(), batchStatus)
val success = InstallRepository.installArchive(path, forceReinstall = true) { pct, _ ->
val overall = ((index + (pct / 100f)) / total.toFloat()) * 100f
onProgress(operationId, overall.toInt(), batchStatus)
}
if (success) {
successCount++
}
}
val failureCount = total - successCount
return when {
successCount == total -> InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_archive_batch, successCount),
deleteOptions = deleteOptions
)
successCount > 0 -> InstallResult(
status = InstallResultStatus.PARTIAL,
message = getString(R.string.install_partial_archive_batch, successCount, failureCount),
deleteOptions = deleteOptions
)
else -> InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_archive_batch, total),
deleteOptions = deleteOptions
)
}
}
private suspend fun installLicense(path: String): InstallResult {
val deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_license_file, path)
)
val success = InstallRepository.copyLicense(path)
return if (success) {
InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_license),
deleteOptions = deleteOptions
)
} else {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_license),
deleteOptions = deleteOptions
)
}
}
private suspend fun installLicenseFromZrif(zrif: String): InstallResult {
val success = InstallRepository.createLicense(zrif)
return if (success) {
InstallResult(
status = InstallResultStatus.SUCCESS,
message = getString(R.string.install_success_license)
)
} else {
InstallResult(
status = InstallResultStatus.ERROR,
message = getString(R.string.install_failed_license_zrif)
)
}
}
private fun onProgress(operationId: Long, percent: Int, status: String) {
InstallServiceController.updateProgress(operationId, percent, status)
updateNotification(status)
}
private fun buildNotification(status: String) =
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.install_progress_title))
.setContentText(status)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
.build()
private fun updateNotification(status: String) {
ensureNotificationChannel()
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(status))
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(R.string.install_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.install_notification_channel_desc)
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"org.vita3k.emulator:install"
).apply {
setReferenceCounted(false)
acquire(WAKE_LOCK_TIMEOUT_MS)
}
}
private fun releaseWakeLock() {
wakeLock?.let { lock ->
if (lock.isHeld) {
lock.release()
}
}
wakeLock = null
}
private fun deleteOption(labelResId: Int, path: String?): DeleteSourceOption? =
deleteOption(labelResId, listOfNotNull(path))
private fun deleteOption(labelResId: Int, paths: List<String>): DeleteSourceOption? {
val normalized = paths
.filter(String::isNotBlank)
.distinct()
if (normalized.isEmpty()) {
return null
}
return DeleteSourceOption(
label = getString(labelResId),
paths = normalized
)
}
private fun sourceDeleteOptions(vararg options: DeleteSourceOption?): List<DeleteSourceOption> =
options.filterNotNull()
companion object {
private const val NOTIFICATION_CHANNEL_ID = "install_progress"
private const val NOTIFICATION_ID = 0x317
private const val WAKE_LOCK_TIMEOUT_MS = 6L * 60L * 60L * 1000L
}
}
@@ -0,0 +1,132 @@
package org.vita3k.emulator
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.vita3k.emulator.ui.viewmodel.InstallResult
import java.util.concurrent.atomic.AtomicLong
enum class InstallOperationType {
FIRMWARE,
PKG,
ARCHIVE,
ARCHIVE_FOLDER,
LICENSE_FILE,
LICENSE_ZRIF
}
data class InstallServiceState(
val operationId: Long = 0L,
val operationType: InstallOperationType? = null,
val installing: Boolean = false,
val progress: Int = 0,
val statusMessage: String = "",
val installResult: InstallResult? = null
)
object InstallServiceController {
const val EXTRA_OPERATION_ID = "operation_id"
const val EXTRA_OPERATION_TYPE = "operation_type"
const val EXTRA_PATH = "path"
const val EXTRA_PATHS = "paths"
const val EXTRA_ZRIF = "zrif"
const val EXTRA_LICENSE_PATH = "license_path"
private val nextOperationId = AtomicLong(1L)
private val mutableState = MutableStateFlow(InstallServiceState())
val state: StateFlow<InstallServiceState> = mutableState
fun startFirmware(context: Context, path: String): Long =
start(context, InstallOperationType.FIRMWARE, context.getString(org.vita3k.emulator.R.string.install_status_firmware)) {
putExtra(EXTRA_PATH, path)
}
fun startPkg(context: Context, path: String, zrif: String, licensePath: String?): Long =
start(context, InstallOperationType.PKG, context.getString(org.vita3k.emulator.R.string.install_status_package)) {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_ZRIF, zrif)
putExtra(EXTRA_LICENSE_PATH, licensePath)
}
fun startArchive(context: Context, path: String): Long =
start(context, InstallOperationType.ARCHIVE, context.getString(org.vita3k.emulator.R.string.install_status_archive)) {
putExtra(EXTRA_PATH, path)
}
fun startArchiveFolder(context: Context, paths: List<String>): Long =
start(context, InstallOperationType.ARCHIVE_FOLDER, context.getString(org.vita3k.emulator.R.string.install_status_archive)) {
putStringArrayListExtra(EXTRA_PATHS, ArrayList(paths))
}
fun startLicenseFile(context: Context, path: String): Long =
start(context, InstallOperationType.LICENSE_FILE, context.getString(org.vita3k.emulator.R.string.install_status_license)) {
putExtra(EXTRA_PATH, path)
}
fun startLicenseZrif(context: Context, zrif: String): Long =
start(context, InstallOperationType.LICENSE_ZRIF, context.getString(org.vita3k.emulator.R.string.install_status_license)) {
putExtra(EXTRA_ZRIF, zrif)
}
fun updateProgress(operationId: Long, percent: Int, status: String) {
mutableState.update { state ->
if (state.operationId != operationId) {
state
} else {
state.copy(
installing = true,
progress = percent.coerceIn(0, 100),
statusMessage = status
)
}
}
}
fun finish(operationId: Long, result: InstallResult, progress: Int = 100, status: String = "") {
mutableState.update { state ->
if (state.operationId != operationId) {
state
} else {
state.copy(
installing = false,
progress = progress.coerceIn(0, 100),
statusMessage = status.ifBlank { state.statusMessage },
installResult = result
)
}
}
}
fun clearResult() {
mutableState.update { state ->
state.copy(installResult = null)
}
}
private inline fun start(
context: Context,
type: InstallOperationType,
initialStatus: String,
extras: Intent.() -> Unit
): Long {
val operationId = nextOperationId.getAndIncrement()
mutableState.value = InstallServiceState(
operationId = operationId,
operationType = type,
installing = true,
statusMessage = initialStatus
)
val intent = Intent(context, InstallForegroundService::class.java).apply {
putExtra(EXTRA_OPERATION_ID, operationId)
putExtra(EXTRA_OPERATION_TYPE, type.name)
extras()
}
ContextCompat.startForegroundService(context, intent)
return operationId
}
}
@@ -0,0 +1,248 @@
package org.vita3k.emulator
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import org.vita3k.emulator.data.AppStorage
import org.vita3k.emulator.ui.navigation.AppNavigation
import org.vita3k.emulator.ui.theme.Vita3KTheme
import org.vita3k.emulator.ui.viewmodel.AppsListViewModel
import org.vita3k.emulator.ui.viewmodel.InstallViewModel
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
class MainActivity : AppCompatActivity() {
private val appsListViewModel: AppsListViewModel by viewModels()
private val installViewModel: InstallViewModel by viewModels()
private val settingsViewModel: SettingsViewModel by viewModels()
private val userManagementViewModel: UserManagementViewModel by viewModels()
private val emulatorLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (appsListViewModel.initialized) {
appsListViewModel.reloadAppsList()
}
}
private var pendingFolderCallback: ((String?) -> Unit)? = null
private var pendingFileCallback: ((String?) -> Unit)? = null
private var pendingArchiveFolderCallback: ((List<String>) -> Unit)? = null
private var pendingInstallFileExtensions: Set<String>? = null
private var pendingArchiveFolderExtensions: Set<String>? = null
private var pendingStorageAction: (() -> Unit)? = null
private val folderPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
if (StorageAccess.hasStorageAccess(this)) {
launchPendingStorageAction()
} else {
cancelPendingStorageRequest()
}
}
private val manageFolderAccessLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (StorageAccess.hasStorageAccess(this)) {
launchPendingStorageAction()
} else {
cancelPendingStorageRequest()
}
}
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val selectedPath = if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> StorageAccess.resolveUriToPath(this, uri) }
} else {
null
}
val validatedPath = selectedPath?.takeIf { path ->
pendingInstallFileExtensions?.let { allowed ->
StorageAccess.matchesAllowedExtension(path, allowed)
} ?: true
}
dispatchFileResult(validatedPath)
}
private val folderPickerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (pendingArchiveFolderCallback != null) {
val selectedPaths = if (result.resultCode == RESULT_OK) {
result.data?.data?.let { treeUri ->
StorageAccess.resolveTreeFilePaths(
context = this,
treeUri = treeUri,
allowedExtensions = pendingArchiveFolderExtensions ?: emptySet()
)
} ?: emptyList()
} else {
emptyList()
}
dispatchArchiveFolderResult(selectedPaths)
return@registerForActivityResult
}
val selectedPath = if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> StorageAccess.resolveTreeUriToPath(this, uri) }
} else {
null
}
dispatchFolderResult(selectedPath)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prepareFrontendRuntime()
setTheme(R.style.Theme_Vita3K)
val storagePath = AppStorage.storageRootPath(this)
appsListViewModel.initialize(storagePath)
setContent {
Vita3KTheme {
AppNavigation(
appsListViewModel = appsListViewModel,
installViewModel = installViewModel,
settingsViewModel = settingsViewModel,
userManagementViewModel = userManagementViewModel,
onAppLaunch = { app -> launchApp(app.titleId, app.title) }
)
}
}
}
fun requestStorageFolderChange(onResult: (String?) -> Unit) {
requestFolderPath(onResult)
}
fun requestFolderPath(onResult: (String?) -> Unit) {
pendingFolderCallback = onResult
pendingFileCallback = null
pendingArchiveFolderCallback = null
pendingInstallFileExtensions = null
pendingArchiveFolderExtensions = null
pendingStorageAction = { launchFolderPicker() }
ensureStorageAccess()
}
fun requestFilePath(mimeTypes: Array<String>, onResult: (String?) -> Unit) {
pendingFileCallback = onResult
pendingFolderCallback = null
pendingArchiveFolderCallback = null
pendingInstallFileExtensions = null
pendingArchiveFolderExtensions = null
pendingStorageAction = { launchFilePicker(mimeTypes) }
ensureStorageAccess()
}
fun requestInstallFilePath(
allowedExtensions: Set<String>,
onResult: (String?) -> Unit
) {
pendingFileCallback = onResult
pendingFolderCallback = null
pendingArchiveFolderCallback = null
pendingInstallFileExtensions = allowedExtensions
pendingArchiveFolderExtensions = null
pendingStorageAction = { launchInstallFilePicker() }
ensureStorageAccess()
}
fun requestArchiveFolderPaths(
allowedExtensions: Set<String>,
onResult: (List<String>) -> Unit
) {
pendingFileCallback = null
pendingFolderCallback = null
pendingArchiveFolderCallback = onResult
pendingInstallFileExtensions = null
pendingArchiveFolderExtensions = allowedExtensions
pendingStorageAction = { launchInstallFolderPicker() }
ensureStorageAccess()
}
override fun onResume() {
super.onResume()
prepareFrontendRuntime()
}
private fun ensureStorageAccess() {
if (StorageAccess.hasStorageAccess(this)) {
launchPendingStorageAction()
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
manageFolderAccessLauncher.launch(StorageAccess.createManageAllFilesIntent(this))
return
}
folderPermissionLauncher.launch(StorageAccess.missingStoragePermissions(this))
}
private fun launchApp(titleId: String, appTitle: String) {
emulatorLauncher.launch(Emulator.createLaunchIntent(this, titleId, appTitle))
}
private fun launchFilePicker(mimeTypes: Array<String>) {
filePickerLauncher.launch(StorageAccess.createFilePickerIntent(mimeTypes))
}
private fun launchInstallFilePicker() {
filePickerLauncher.launch(StorageAccess.createInstallFilePickerIntent())
}
private fun launchFolderPicker() {
folderPickerLauncher.launch(StorageAccess.createFolderPickerIntent())
}
private fun launchInstallFolderPicker() {
folderPickerLauncher.launch(StorageAccess.createInstallFolderPickerIntent())
}
private fun launchPendingStorageAction() {
val action = pendingStorageAction ?: return
pendingStorageAction = null
action.invoke()
}
private fun cancelPendingStorageRequest() {
pendingStorageAction = null
dispatchFileResult(null)
dispatchFolderResult(null)
dispatchArchiveFolderResult(emptyList())
}
private fun dispatchFolderResult(path: String?) {
val callback = pendingFolderCallback
pendingFolderCallback = null
callback?.invoke(path?.takeIf { it.isNotBlank() })
}
private fun dispatchFileResult(path: String?) {
val callback = pendingFileCallback
pendingFileCallback = null
pendingInstallFileExtensions = null
callback?.invoke(path?.takeIf { it.isNotBlank() })
}
private fun dispatchArchiveFolderResult(paths: List<String>) {
val callback = pendingArchiveFolderCallback
pendingArchiveFolderCallback = null
pendingArchiveFolderExtensions = null
callback?.invoke(paths)
}
private fun prepareFrontendRuntime() {
NativeLib.prepareFrontend()
}
}
@@ -0,0 +1,153 @@
package org.vita3k.emulator
import org.vita3k.emulator.data.EmulatorConfig
import org.vita3k.emulator.data.InstallCallback
import org.vita3k.emulator.data.NativeAppInfo
import org.vita3k.emulator.data.NativeUser
/**
* JNI bridge to the Vita3K C++ backend.
* All methods are implemented in the native Android JNI modules under vita3k/android/jni/.
* Game boot/stop is handled by the SDL-based Emulator activity.
*/
object NativeLib {
const val PAUSE_REASON_USER = 1 shl 0
const val PAUSE_REASON_MENU = 1 shl 1
const val PAUSE_REASON_BACKGROUND = 1 shl 2
// --- Initialization ---
external fun prepareFrontend(): Boolean
external fun init(storagePath: String): Boolean
external fun isInitialized(): Boolean
external fun isOfficialBuild(): Boolean
// --- Apps list ---
/** Returns detailed app info objects. */
external fun getAppListDetailed(): Array<NativeAppInfo>
external fun refreshAppsList()
// --- App actions ---
/**
* Dispatches a single app action identified by its AppActionMask bit.
* See AppAction.maskBit for the values.
*/
external fun performAppAction(titleId: String, actionBit: Int): Boolean
/** Returns a bitmask of available actions for the given title (AppActionMask bits). */
external fun getAppActionAvailabilityMask(titleId: String): Int
// --- Firmware / Info ---
external fun getAppVersion(): String
external fun getFirmwareInstallStateMask(): Int
external fun getCompatibilityDatabaseVersion(): String
external fun installCompatibilityDatabase(zipData: ByteArray, version: String): Boolean
/** Returns the installed application size in bytes, or 0 on error. */
external fun getAppInstallSize(titleId: String): Long
// --- Installation ---
/** Installs firmware from PUP file. Returns version string on success, empty on failure. */
external fun installFirmware(path: String, callback: InstallCallback): String
/** Installs a .pkg file. Pass zrif="" to skip license. */
external fun installPkg(path: String, zrif: String, callback: InstallCallback): Boolean
/**
* Installs a .zip or .vpk archive.
* @param forceReinstall If true, silently reinstalls if already present (no prompt).
*/
external fun installArchive(path: String, callback: InstallCallback, forceReinstall: Boolean): Boolean
/** Copies a .rif or .bin license file. */
external fun copyLicense(path: String): Boolean
/** Creates and installs a license from a zRIF key. */
external fun createLicense(zrif: String): Boolean
/** Auto-finds a zRIF for a PKG from existing licenses. Returns empty string if not found. */
external fun findPkgZrif(pkgPath: String): String
/** Converts a .bin/.rif license file to a zRIF string. Returns empty string on failure. */
external fun convertRifToZrif(path: String): String
// --- Settings / Config ---
/** Returns the current global EmulatorConfig (all CurrentConfig + global fields). */
external fun getGlobalConfig(): EmulatorConfig
/** Returns the currently active emulated storage folder path. */
external fun getCurrentEmulatorPath(): String
/** Updates the active emulated storage folder path without changing the app storage root. */
external fun setCurrentEmulatorPath(path: String): Boolean
/** Returns a fresh EmulatorConfig populated with emulator default values. */
external fun getDefaultConfig(): EmulatorConfig
/** Returns true when a per-app custom config file exists for [titleId]. */
external fun hasCustomConfig(titleId: String): Boolean
/** Returns the saved custom config for [titleId], or null when none exists. */
external fun getCustomConfig(titleId: String): EmulatorConfig?
/**
* Saves either the global config (when [titleId] is null) or a per-app custom config.
* Returns the ids of the changed settings that still need a game restart when this save affected the active session.
*/
external fun saveSettings(titleId: String?, config: EmulatorConfig): IntArray
/**
* Deletes the per-app custom config file for [titleId].
* Returns the ids of the changed settings that still need a game restart when this reset affected the active session.
*/
external fun deleteCustomConfig(titleId: String): IntArray
/** Deletes all per-app custom config files and returns how many were removed. */
external fun clearAllCustomConfigs(): Int
/** Returns the support bitmask for Vulkan memory-mapping methods for the selected Android GPU driver. */
external fun getSupportedMemoryMappingMask(customDriverName: String): Int
/**
* Returns [supportedMask, status] for the selected Android GPU driver.
* status: 0 = default driver, 1 = custom driver loaded, 2 = custom driver fell back to default.
*/
external fun getCustomDriverSupportInfo(customDriverName: String): IntArray
/** Returns true when the Android custom GPU driver option should be shown on this device. */
external fun shouldShowCustomDriverOptions(): Boolean
/** Returns the names of the camera devices currently visible to SDL. */
external fun getAvailableCameras(): Array<String>
/** Returns a flat array [label0, mask0, label1, mask1, ...] of available ad-hoc addresses. */
external fun getAvailableAdhocAddresses(): Array<String>
/** Returns the names of all installed custom GPU drivers. */
external fun getInstalledCustomDrivers(): Array<String>
/** Installs a custom GPU driver from a ZIP archive and returns its installed name, or empty on failure. */
external fun installCustomDriver(path: String): String
/** Removes an installed custom GPU driver by name. */
external fun removeCustomDriver(driverName: String): Boolean
// --- Users ---
/** Returns all frontend-visible Vita users with their active state. */
external fun getUsers(): Array<NativeUser>
/** Creates a user and returns its ID, or an empty string on failure. */
external fun createUser(name: String): String
/** Activates the selected user and persists the choice. */
external fun activateUser(userId: String): Boolean
/** Deletes the selected user and updates the active user when needed. */
external fun deleteUser(userId: String): Boolean
// --- Running app session ---
/** Blocks or releases gameplay input without changing the native pause state. */
external fun setInputIntercepted(intercepted: Boolean): Boolean
/** Enables or clears a specific native pause reason bit for the running app session. */
external fun setPauseReasonEnabled(reasonMask: Int, enabled: Boolean): Boolean
/** Returns true when an app session is currently active. */
external fun isAppRunning(): Boolean
/** Requests a graceful shutdown of the currently running app session. */
external fun requestAppQuit(): Boolean
/** Requests an orderly in-process relaunch into another app or executable. */
external fun requestAppRelaunch(
titleId: String,
selfPath: String,
args: Array<String>,
loadExecReason: Boolean
): Boolean
/** Returns true when the currently running app session is paused. */
external fun isAppPaused(): Boolean
/** Returns the title of the currently running app session, or an empty string when unavailable. */
external fun getRunningAppTitle(): String
/** Returns true while either SceIme or CommonDialog IME is active. */
external fun isImeActive(): Boolean
/** Submits the currently active IME session as if the user pressed Enter/OK. */
external fun submitIme(): Boolean
/** Dismisses the currently active IME session as if the user pressed Close/Back. */
external fun dismissIme(): Boolean
/**
* Returns a flat array [name0, "true"/"false", name1, "true"/"false", ...] listing all LLE
* modules available in the installed firmware. Each pair is (module name, enabled state),
* where "true" means the module is in [currentModules].
*/
external fun getModulesList(currentModules: Array<String>): Array<String>
}
@@ -0,0 +1,138 @@
package org.vita3k.emulator
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
object StorageAccess {
@JvmStatic
fun hasStorageAccess(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
missingStoragePermissions(context).isEmpty()
}
}
@JvmStatic
fun missingStoragePermissions(context: Context): Array<String> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return emptyArray()
}
return arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
).filter { permission ->
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
}.toTypedArray()
}
@JvmStatic
fun createManageAllFilesIntent(context: Context): Intent {
return Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
.setData(Uri.parse("package:${context.packageName}"))
}
@JvmStatic
fun createFilePickerIntent(mimeTypes: Array<String>): Intent {
val normalizedMimeTypes = mimeTypes
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
val primaryMimeType = if (normalizedMimeTypes.size == 1) {
normalizedMimeTypes.first()
} else {
"*/*"
}
return Intent(Intent.ACTION_GET_CONTENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(primaryMimeType)
.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
.apply {
if (normalizedMimeTypes.size > 1) {
putExtra(Intent.EXTRA_MIME_TYPES, normalizedMimeTypes.toTypedArray())
}
}
}
@JvmStatic
fun createInstallFilePickerIntent(): Intent {
return Intent(Intent.ACTION_GET_CONTENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
}
@JvmStatic
fun createFolderPickerIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
}
@JvmStatic
fun createInstallFolderPickerIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}
@JvmStatic
fun resolveUriToPath(context: Context, uri: Uri): String? {
return runCatching {
val descriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return null
val rawPath = descriptor.use { android.system.Os.readlink("/proc/self/fd/${it.fd}") }
normalizeResolvedPath(rawPath)
}.getOrNull()
}
@JvmStatic
fun resolveTreeUriToPath(context: Context, treeUri: Uri): String? {
val resolvedTreeUri = DocumentFile.fromTreeUri(context, treeUri)?.uri ?: treeUri
return resolveUriToPath(context, resolvedTreeUri)
}
@JvmStatic
fun resolveTreeFilePaths(context: Context, treeUri: Uri, allowedExtensions: Set<String>): List<String> {
val tree = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
val normalizedExtensions = allowedExtensions
.map(String::lowercase)
.toSet()
return tree.listFiles()
.asSequence()
.filter(DocumentFile::isFile)
.filter { document -> matchesAllowedExtension(document.name, normalizedExtensions) }
.mapNotNull { document -> resolveUriToPath(context, document.uri) }
.filter(String::isNotBlank)
.distinct()
.sorted()
.toList()
}
@JvmStatic
fun matchesAllowedExtension(pathOrName: String?, allowedExtensions: Set<String>): Boolean {
if (allowedExtensions.isEmpty()) {
return true
}
val lowerName = pathOrName?.lowercase() ?: return false
return allowedExtensions.any(lowerName::endsWith)
}
private fun normalizeResolvedPath(rawPath: String): String {
if (!rawPath.startsWith("/mnt/user/")) {
return rawPath
}
val relativePath = rawPath.removePrefix("/mnt/user/")
return "/storage" + relativePath.substring(relativePath.indexOf('/'))
}
}
@@ -0,0 +1,14 @@
package org.vita3k.emulator
import android.app.Application
import org.libsdl.app.SDLActivity
import org.vita3k.emulator.data.UiLanguages
class Vita3KApplication : Application() {
override fun onCreate() {
super.onCreate()
System.loadLibrary("Vita3K")
SDLActivity.nativeSetenv("SDL_ANDROID_ALLOW_RECREATE_ACTIVITY", "1")
UiLanguages.applyStored(this)
}
}
@@ -0,0 +1,71 @@
package org.vita3k.emulator;
import android.content.Context;
import android.view.KeyEvent;
import android.view.View;
import org.libsdl.app.SDLInputConnection;
public class VitaInputConnection extends SDLInputConnection {
public VitaInputConnection(View targetView, boolean fullEditor) {
super(targetView, fullEditor);
}
private boolean isNativeImeActive() {
try {
return NativeLib.INSTANCE.isImeActive();
} catch (Throwable ignored) {
return false;
}
}
private Emulator getEmulator() {
Context context = mEditText.getContext();
return context instanceof Emulator ? (Emulator) context : null;
}
public void clearConnectionState() {
android.text.Editable content = getEditable();
if (content != null) {
content.clear();
removeComposingSpans(content);
}
mCommittedText = "";
}
private void submitFromKeyboard() {
Emulator emulator = getEmulator();
if (emulator != null) {
emulator.completeImeFromKeyboard(mEditText);
} else {
try {
NativeLib.INSTANCE.submitIme();
} catch (Throwable ignored) {
}
clearConnectionState();
}
}
@Override
public boolean performEditorAction(int actionCode) {
if (isNativeImeActive()) {
submitFromKeyboard();
return true;
}
return super.performEditorAction(actionCode);
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
if (isNativeImeActive()
&& event.getAction() == KeyEvent.ACTION_UP
&& (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|| event.getKeyCode() == KeyEvent.KEYCODE_NUMPAD_ENTER)) {
submitFromKeyboard();
return true;
}
return super.sendKeyEvent(event);
}
}
@@ -0,0 +1,94 @@
package org.vita3k.emulator;
import android.content.Context;
import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import org.libsdl.app.SDLDummyEdit;
import java.lang.reflect.Field;
public class VitaTextEdit extends SDLDummyEdit {
private static final Field IC_FIELD = resolveField("ic");
private static final Field INPUT_TYPE_FIELD = resolveField("input_type");
public VitaTextEdit(Context context) {
super(context);
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
InputConnection connection = new VitaInputConnection(this, true);
setFieldValue(IC_FIELD, this, connection);
outAttrs.inputType = getInputTypeValue(this);
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
EditorInfo.IME_FLAG_NO_FULLSCREEN;
return connection;
}
@Override
public boolean onKeyPreIme(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) {
try {
if (NativeLib.INSTANCE.isImeActive()) {
Context context = getContext();
if (context instanceof Emulator) {
((Emulator) context).dismissImeFromKeyboard(this);
return true;
}
}
} catch (Throwable ignored) {
}
}
return super.onKeyPreIme(keyCode, event);
}
public void clearConnectionState() {
Object connection = getFieldValue(IC_FIELD, this);
if (connection instanceof VitaInputConnection) {
((VitaInputConnection) connection).clearConnectionState();
}
}
public static int getInputTypeValue(SDLDummyEdit edit) {
Object value = getFieldValue(INPUT_TYPE_FIELD, edit);
return value instanceof Integer ? (Integer) value : 0;
}
private static Field resolveField(String name) {
try {
Field field = SDLDummyEdit.class.getDeclaredField(name);
field.setAccessible(true);
return field;
} catch (Exception e) {
return null;
}
}
private static Object getFieldValue(Field field, Object target) {
if (field == null || target == null) {
return null;
}
try {
return field.get(target);
} catch (Exception e) {
return null;
}
}
private static void setFieldValue(Field field, Object target, Object value) {
if (field == null || target == null) {
return;
}
try {
field.set(target, value);
} catch (Exception ignored) {
}
}
}
@@ -0,0 +1,49 @@
package org.vita3k.emulator.data
import androidx.annotation.StringRes
import java.io.File
import org.vita3k.emulator.R
enum class CompatibilityState(
val value: Int,
val colorHex: Long,
val onColorHex: Long,
@StringRes val labelResId: Int
) {
UNKNOWN(-1, 0xFF8A8A8A, 0xFFFFFFFF, R.string.compat_unknown),
NOTHING(0, 0xFFFF0000, 0xFFFFFFFF, R.string.compat_nothing),
BOOTABLE(1, 0xFF621FA5, 0xFFFFFFFF, R.string.compat_bootable),
INTRO(2, 0xFFC71585, 0xFFFFFFFF, R.string.compat_intro),
MENU(3, 0xFF1D76DB, 0xFFFFFFFF, R.string.compat_menu),
INGAME_LESS(4, 0xFFE08A1E, 0xFFFFFFFF, R.string.compat_ingame_less),
INGAME_MORE(5, 0xFFFFD700, 0xFF000000, R.string.compat_ingame_more),
PLAYABLE(6, 0xFF0E8A16, 0xFFFFFFFF, R.string.compat_playable);
companion object {
fun fromValue(value: Int): CompatibilityState =
entries.find { it.value == value } ?: UNKNOWN
}
}
data class AppInfo(
val titleId: String,
val title: String,
val category: String = "",
val appVer: String = "",
val iconPath: String? = null,
val hasCustomConfig: Boolean = false,
val compatibility: CompatibilityState = CompatibilityState.UNKNOWN,
val lastPlayed: Long = 0,
val playtime: Long = 0
) {
val iconFile: File?
get() = iconPath?.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.exists() }
}
enum class SortOption {
TITLE, LAST_PLAYED, COMPATIBILITY
}
enum class ViewMode {
GRID, LIST
}
@@ -0,0 +1,213 @@
package org.vita3k.emulator.data
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.vita3k.emulator.NativeLib
import org.vita3k.emulator.ui.viewmodel.AppAction
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
internal object AppRepository {
private const val TAG = "Vita3K"
private const val COMPAT_VERSION_URL =
"https://api.github.com/repos/Vita3K/compatibility/releases/latest"
private const val COMPAT_DB_URL =
"https://github.com/Vita3K/compatibility/releases/download/compat_db/app_compat_db.xml.zip"
private const val UPDATE_RELEASE_URL =
"https://api.github.com/repos/Vita3K/Vita3K/releases/tags/continuous"
private const val UPDATE_PAGE_URL =
"https://github.com/Vita3K/Vita3K/releases/tag/continuous"
private val compatVersionRegex =
Regex("""Last updated: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z)""")
private val updateBuildRegex = Regex("""Vita3K Build:\s*(\d+)""")
private val currentBuildRegex = Regex("""^[^-]+-(\d+)(?:-|$)""")
private val updateMetadataLineRegex =
Regex("""^\s*(Corresponding commit:|Vita3K Build:)""", RegexOption.IGNORE_CASE)
suspend fun initialize(storagePath: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.init(storagePath)
}
suspend fun getAppVersion(): String = withContext(Dispatchers.IO) {
NativeLib.getAppVersion()
}
suspend fun getFirmwareInstallState(): FirmwareInstallState = withContext(Dispatchers.IO) {
FirmwareInstallState.fromMask(NativeLib.getFirmwareInstallStateMask())
}
suspend fun syncCompatibilityDatabase(): Boolean = withContext(Dispatchers.IO) {
try {
val currentVersion = NativeLib.getCompatibilityDatabaseVersion()
val versionResponse = httpGetString(COMPAT_VERSION_URL) ?: return@withContext false
val latestVersion = compatVersionRegex.find(versionResponse)?.groupValues?.getOrNull(1)
?: return@withContext false
if (latestVersion == currentVersion) {
return@withContext false
}
val zipData = httpGetBytes(COMPAT_DB_URL) ?: return@withContext false
NativeLib.installCompatibilityDatabase(zipData, latestVersion)
} catch (error: Exception) {
Log.w(TAG, "Failed to sync compatibility database", error)
false
}
}
suspend fun getAppList(): List<AppInfo> = withContext(Dispatchers.IO) {
NativeLib.getAppListDetailed().map(::toAppInfo)
}
suspend fun refreshAppsList() = withContext(Dispatchers.IO) {
NativeLib.refreshAppsList()
}
suspend fun runAppAction(titleId: String, action: AppAction): Boolean =
withContext(Dispatchers.IO) {
NativeLib.performAppAction(titleId, action.maskBit)
}
suspend fun getAvailableAppActions(titleId: String): Set<AppAction> =
withContext(Dispatchers.IO) {
AppAction.fromMask(NativeLib.getAppActionAvailabilityMask(titleId))
}
suspend fun getAppInstallSize(titleId: String): Long = withContext(Dispatchers.IO) {
NativeLib.getAppInstallSize(titleId)
}
suspend fun checkForUpdates(
appVersion: String,
officialBuild: Boolean
): UpdateCheckResult = withContext(Dispatchers.IO) {
val currentDisplayVersion = currentDisplayVersion(appVersion)
val response = httpGetString(UPDATE_RELEASE_URL)
?: return@withContext UpdateCheckResult(
status = UpdateCheckStatus.Failed,
message = "Could not retrieve the latest Vita3K release information. Please try again later.",
currentDisplayVersion = currentDisplayVersion
)
val root = try {
JSONObject(response)
} catch (_: Exception) {
return@withContext UpdateCheckResult(
status = UpdateCheckStatus.Failed,
message = "The GitHub release response could not be parsed.",
currentDisplayVersion = currentDisplayVersion
)
}
val body = root.optString("body").orEmpty()
val latestBuildNumber = updateBuildRegex.find(body)?.groupValues?.getOrNull(1)?.toLongOrNull()
?: return@withContext UpdateCheckResult(
status = UpdateCheckStatus.Failed,
message = "The latest release does not advertise a usable Vita3K build number.",
currentDisplayVersion = currentDisplayVersion
)
val releaseName = root.optString("name").trim()
val tagName = root.optString("tag_name").trim()
val releaseUrl = root.optString("html_url").trim().ifBlank { UPDATE_PAGE_URL }
val info = UpdateInfo(
version = releaseName.ifBlank { tagName },
buildNumber = latestBuildNumber,
releaseUrl = releaseUrl,
publishedAt = root.optString("published_at").trim(),
notes = normalizedUpdateNotes(body)
)
val currentBuildNumber = currentBuildRegex.find(appVersion)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0L
val buildDelta = latestBuildNumber - currentBuildNumber
when {
!officialBuild -> UpdateCheckResult(
status = UpdateCheckStatus.CustomBuildCanUpdate,
message = "",
info = info,
currentDisplayVersion = currentDisplayVersion
)
buildDelta > 0L -> UpdateCheckResult(
status = UpdateCheckStatus.UpdateAvailable,
message = "",
info = info,
currentDisplayVersion = currentDisplayVersion
)
buildDelta < 0L -> UpdateCheckResult(
status = UpdateCheckStatus.CurrentBuildNewerThanLatest,
message = "This installation appears newer than the latest official continuous build.",
info = info,
currentDisplayVersion = currentDisplayVersion
)
else -> UpdateCheckResult(
status = UpdateCheckStatus.UpToDate,
message = "You already have the latest official build installed.",
info = info,
currentDisplayVersion = currentDisplayVersion
)
}
}
private fun toAppInfo(native: NativeAppInfo): AppInfo = AppInfo(
titleId = native.titleId,
title = native.title,
category = native.category,
appVer = native.appVer,
iconPath = native.iconPath.ifEmpty { null },
hasCustomConfig = native.hasCustomConfig,
compatibility = CompatibilityState.fromValue(native.compatibility),
lastPlayed = native.lastPlayed,
playtime = native.playtime
)
private fun currentDisplayVersion(appVersion: String): String {
if (appVersion.isBlank()) {
return ""
}
val parts = appVersion.split("-", limit = 3)
val version = parts.getOrNull(0).orEmpty().ifBlank { appVersion }
val build = parts.getOrNull(1).orEmpty()
return if (build.isBlank()) version else "$version ($build)"
}
private fun normalizedUpdateNotes(body: String): String {
val cleaned = body
.replace("\r\n", "\n")
.lineSequence()
.filterNot { line -> updateMetadataLineRegex.containsMatchIn(line.trim()) }
.dropWhile { it.isBlank() }
.toList()
.dropLastWhile { it.isBlank() }
return cleaned.joinToString("\n").trim()
}
private fun httpGetString(url: String): String? =
httpGetBytes(url)?.toString(Charsets.UTF_8)
private fun httpGetBytes(url: String): ByteArray? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
instanceFollowRedirects = true
connectTimeout = 10000
readTimeout = 15000
setRequestProperty("User-Agent", "Vita3K-Android")
setRequestProperty("Accept", "application/vnd.github+json")
}
return try {
connection.connect()
if (connection.responseCode !in 200..299) {
null
} else {
connection.inputStream.use { it.readBytes() }
}
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,30 @@
package org.vita3k.emulator.data
import android.content.Context
import java.io.File
internal object AppStorage {
private const val PREFS_NAME = "vita3k_app"
private const val KEY_INITIAL_SETUP_COMPLETED = "initial_setup_completed"
private const val DEFAULT_STORAGE_DIR_NAME = "vita"
fun storageRootPath(context: Context): String {
return (context.getExternalFilesDir(null) ?: context.filesDir ?: File(context.cacheDir, "files")).absolutePath
}
fun defaultStoragePath(context: Context): String {
val baseDir = File(storageRootPath(context))
return File(baseDir, DEFAULT_STORAGE_DIR_NAME).absolutePath
}
fun isInitialSetupCompleted(context: Context): Boolean =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean(KEY_INITIAL_SETUP_COMPLETED, false)
fun setInitialSetupCompleted(context: Context, completed: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_INITIAL_SETUP_COMPLETED, completed)
.apply()
}
}
@@ -0,0 +1,339 @@
package org.vita3k.emulator.data
/**
* Mirrors the JNI-backed emulator settings object.
*
* All properties are exposed as @JvmField vars so the native layer can access
* them directly after constructing this class with the no-arg constructor.
*/
class EmulatorConfig {
// Core
@JvmField var modulesMode: Int = 0
@JvmField var lleModules: Array<String> = emptyArray()
@JvmField var cpuOpt: Boolean = true
// GPU
@JvmField var backendRenderer: String = "Vulkan"
@JvmField var highAccuracy: Boolean = false
@JvmField var resolutionMultiplier: Float = 1.0f
@JvmField var disableSurfaceSync: Boolean = true
@JvmField var screenFilter: String = "Bilinear"
@JvmField var memoryMapping: String = "double-buffer"
@JvmField var vSync: Boolean = true
@JvmField var anisotropicFiltering: Int = 1
@JvmField var asyncPipelineCompilation: Boolean = true
@JvmField var exportTextures: Boolean = false
@JvmField var importTextures: Boolean = false
@JvmField var exportAsPng: Boolean = true
@JvmField var fpsHack: Boolean = false
@JvmField var turboMode: Boolean = false
@JvmField var shaderCache: Boolean = true
@JvmField var spirvShader: Boolean = false
@JvmField var customDriverName: String = ""
// Audio
@JvmField var audioBackend: String = "SDL"
@JvmField var audioVolume: Int = 100
@JvmField var ngsEnable: Boolean = true
@JvmField var disableMotion: Boolean = false
@JvmField var controllerAnalogMultiplier: Float = 1.0f
@JvmField var controllerBinds: IntArray = defaultControllerBinds()
@JvmField var controllerAxisBinds: IntArray = defaultControllerAxisBinds()
// Camera
@JvmField var frontCameraType: Int = 2
@JvmField var frontCameraId: String = ""
@JvmField var frontCameraImage: String = ""
@JvmField var frontCameraColor: Long = 0L
@JvmField var backCameraType: Int = 2
@JvmField var backCameraId: String = ""
@JvmField var backCameraImage: String = ""
@JvmField var backCameraColor: Long = 0L
// System
@JvmField var pstvMode: Boolean = false
@JvmField var showMode: Boolean = false
@JvmField var demoMode: Boolean = false
@JvmField var sysButton: Int = 1
@JvmField var sysLang: Int = 1
@JvmField var sysDateFormat: Int = 2
@JvmField var sysTimeFormat: Int = 0
@JvmField var imeLangs: Long = 4L
@JvmField var userLang: String = ""
// Network
@JvmField var psnSignedIn: Boolean = false
@JvmField var httpEnable: Boolean = true
@JvmField var httpTimeoutAttempts: Int = 50
@JvmField var httpTimeoutSleepMs: Int = 100
@JvmField var httpReadEndAttempts: Int = 10
@JvmField var httpReadEndSleepMs: Int = 250
@JvmField var adhocAddr: Int = 0
// Debug
@JvmField var logImports: Boolean = false
@JvmField var logExports: Boolean = false
@JvmField var logActiveShaders: Boolean = false
@JvmField var logUniforms: Boolean = false
@JvmField var colorSurfaceDebug: Boolean = false
@JvmField var dumpElfs: Boolean = false
@JvmField var validationLayer: Boolean = true
@JvmField var textureCache: Boolean = true
@JvmField var stretchDisplayArea: Boolean = false
@JvmField var fullscreenHdResPixelPerfect: Boolean = false
@JvmField var fileLoadingDelay: Int = 0
// Emulator
@JvmField var showLiveAreaScreen: Boolean = false
@JvmField var showCompileShaders: Boolean = true
@JvmField var checkForUpdates: Boolean = true
@JvmField var checkForUpdatesMode: Int = 1
@JvmField var archiveLog: Boolean = false
@JvmField var logCompatWarn: Boolean = false
@JvmField var logLevel: Int = 0
@JvmField var performanceOverlay: Boolean = false
@JvmField var performanceOverlayDetail: Int = 0
@JvmField var performanceOverlayPosition: Int = 0
@JvmField var screenshotFormat: Int = 1
fun copy(): EmulatorConfig = EmulatorConfig().also { config ->
config.modulesMode = modulesMode
config.lleModules = lleModules.copyOf()
config.cpuOpt = cpuOpt
config.backendRenderer = backendRenderer
config.highAccuracy = highAccuracy
config.resolutionMultiplier = resolutionMultiplier
config.disableSurfaceSync = disableSurfaceSync
config.screenFilter = screenFilter
config.memoryMapping = memoryMapping
config.vSync = vSync
config.anisotropicFiltering = anisotropicFiltering
config.asyncPipelineCompilation = asyncPipelineCompilation
config.exportTextures = exportTextures
config.importTextures = importTextures
config.exportAsPng = exportAsPng
config.fpsHack = fpsHack
config.turboMode = turboMode
config.shaderCache = shaderCache
config.spirvShader = spirvShader
config.customDriverName = customDriverName
config.audioBackend = audioBackend
config.audioVolume = audioVolume
config.ngsEnable = ngsEnable
config.disableMotion = disableMotion
config.controllerAnalogMultiplier = controllerAnalogMultiplier
config.controllerBinds = controllerBinds.copyOf()
config.controllerAxisBinds = controllerAxisBinds.copyOf()
config.frontCameraType = frontCameraType
config.frontCameraId = frontCameraId
config.frontCameraImage = frontCameraImage
config.frontCameraColor = frontCameraColor
config.backCameraType = backCameraType
config.backCameraId = backCameraId
config.backCameraImage = backCameraImage
config.backCameraColor = backCameraColor
config.pstvMode = pstvMode
config.showMode = showMode
config.demoMode = demoMode
config.sysButton = sysButton
config.sysLang = sysLang
config.sysDateFormat = sysDateFormat
config.sysTimeFormat = sysTimeFormat
config.imeLangs = imeLangs
config.userLang = userLang
config.psnSignedIn = psnSignedIn
config.httpEnable = httpEnable
config.httpTimeoutAttempts = httpTimeoutAttempts
config.httpTimeoutSleepMs = httpTimeoutSleepMs
config.httpReadEndAttempts = httpReadEndAttempts
config.httpReadEndSleepMs = httpReadEndSleepMs
config.adhocAddr = adhocAddr
config.logImports = logImports
config.logExports = logExports
config.logActiveShaders = logActiveShaders
config.logUniforms = logUniforms
config.colorSurfaceDebug = colorSurfaceDebug
config.dumpElfs = dumpElfs
config.validationLayer = validationLayer
config.textureCache = textureCache
config.stretchDisplayArea = stretchDisplayArea
config.fullscreenHdResPixelPerfect = fullscreenHdResPixelPerfect
config.fileLoadingDelay = fileLoadingDelay
config.showLiveAreaScreen = showLiveAreaScreen
config.showCompileShaders = showCompileShaders
config.checkForUpdates = checkForUpdates
config.checkForUpdatesMode = checkForUpdatesMode
config.archiveLog = archiveLog
config.logCompatWarn = logCompatWarn
config.logLevel = logLevel
config.performanceOverlay = performanceOverlay
config.performanceOverlayDetail = performanceOverlayDetail
config.performanceOverlayPosition = performanceOverlayPosition
config.screenshotFormat = screenshotFormat
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EmulatorConfig) return false
return modulesMode == other.modulesMode &&
lleModules.contentEquals(other.lleModules) &&
cpuOpt == other.cpuOpt &&
backendRenderer == other.backendRenderer &&
highAccuracy == other.highAccuracy &&
resolutionMultiplier == other.resolutionMultiplier &&
disableSurfaceSync == other.disableSurfaceSync &&
screenFilter == other.screenFilter &&
memoryMapping == other.memoryMapping &&
vSync == other.vSync &&
anisotropicFiltering == other.anisotropicFiltering &&
asyncPipelineCompilation == other.asyncPipelineCompilation &&
exportTextures == other.exportTextures &&
importTextures == other.importTextures &&
exportAsPng == other.exportAsPng &&
fpsHack == other.fpsHack &&
turboMode == other.turboMode &&
shaderCache == other.shaderCache &&
spirvShader == other.spirvShader &&
customDriverName == other.customDriverName &&
audioBackend == other.audioBackend &&
audioVolume == other.audioVolume &&
ngsEnable == other.ngsEnable &&
disableMotion == other.disableMotion &&
controllerAnalogMultiplier == other.controllerAnalogMultiplier &&
controllerBinds.contentEquals(other.controllerBinds) &&
controllerAxisBinds.contentEquals(other.controllerAxisBinds) &&
frontCameraType == other.frontCameraType &&
frontCameraId == other.frontCameraId &&
frontCameraImage == other.frontCameraImage &&
frontCameraColor == other.frontCameraColor &&
backCameraType == other.backCameraType &&
backCameraId == other.backCameraId &&
backCameraImage == other.backCameraImage &&
backCameraColor == other.backCameraColor &&
pstvMode == other.pstvMode &&
showMode == other.showMode &&
demoMode == other.demoMode &&
sysButton == other.sysButton &&
sysLang == other.sysLang &&
sysDateFormat == other.sysDateFormat &&
sysTimeFormat == other.sysTimeFormat &&
imeLangs == other.imeLangs &&
userLang == other.userLang &&
psnSignedIn == other.psnSignedIn &&
httpEnable == other.httpEnable &&
httpTimeoutAttempts == other.httpTimeoutAttempts &&
httpTimeoutSleepMs == other.httpTimeoutSleepMs &&
httpReadEndAttempts == other.httpReadEndAttempts &&
httpReadEndSleepMs == other.httpReadEndSleepMs &&
adhocAddr == other.adhocAddr &&
logImports == other.logImports &&
logExports == other.logExports &&
logActiveShaders == other.logActiveShaders &&
logUniforms == other.logUniforms &&
colorSurfaceDebug == other.colorSurfaceDebug &&
dumpElfs == other.dumpElfs &&
validationLayer == other.validationLayer &&
textureCache == other.textureCache &&
stretchDisplayArea == other.stretchDisplayArea &&
fullscreenHdResPixelPerfect == other.fullscreenHdResPixelPerfect &&
fileLoadingDelay == other.fileLoadingDelay &&
showLiveAreaScreen == other.showLiveAreaScreen &&
showCompileShaders == other.showCompileShaders &&
checkForUpdates == other.checkForUpdates &&
checkForUpdatesMode == other.checkForUpdatesMode &&
archiveLog == other.archiveLog &&
logCompatWarn == other.logCompatWarn &&
logLevel == other.logLevel &&
performanceOverlay == other.performanceOverlay &&
performanceOverlayDetail == other.performanceOverlayDetail &&
performanceOverlayPosition == other.performanceOverlayPosition &&
screenshotFormat == other.screenshotFormat
}
override fun hashCode(): Int {
var result = modulesMode
result = 31 * result + lleModules.contentHashCode()
result = 31 * result + cpuOpt.hashCode()
result = 31 * result + backendRenderer.hashCode()
result = 31 * result + highAccuracy.hashCode()
result = 31 * result + resolutionMultiplier.hashCode()
result = 31 * result + disableSurfaceSync.hashCode()
result = 31 * result + screenFilter.hashCode()
result = 31 * result + memoryMapping.hashCode()
result = 31 * result + vSync.hashCode()
result = 31 * result + anisotropicFiltering
result = 31 * result + asyncPipelineCompilation.hashCode()
result = 31 * result + exportTextures.hashCode()
result = 31 * result + importTextures.hashCode()
result = 31 * result + exportAsPng.hashCode()
result = 31 * result + fpsHack.hashCode()
result = 31 * result + turboMode.hashCode()
result = 31 * result + shaderCache.hashCode()
result = 31 * result + spirvShader.hashCode()
result = 31 * result + customDriverName.hashCode()
result = 31 * result + audioBackend.hashCode()
result = 31 * result + audioVolume
result = 31 * result + ngsEnable.hashCode()
result = 31 * result + disableMotion.hashCode()
result = 31 * result + controllerAnalogMultiplier.hashCode()
result = 31 * result + controllerBinds.contentHashCode()
result = 31 * result + controllerAxisBinds.contentHashCode()
result = 31 * result + frontCameraType
result = 31 * result + frontCameraId.hashCode()
result = 31 * result + frontCameraImage.hashCode()
result = 31 * result + frontCameraColor.hashCode()
result = 31 * result + backCameraType
result = 31 * result + backCameraId.hashCode()
result = 31 * result + backCameraImage.hashCode()
result = 31 * result + backCameraColor.hashCode()
result = 31 * result + pstvMode.hashCode()
result = 31 * result + showMode.hashCode()
result = 31 * result + demoMode.hashCode()
result = 31 * result + sysButton
result = 31 * result + sysLang
result = 31 * result + sysDateFormat
result = 31 * result + sysTimeFormat
result = 31 * result + imeLangs.hashCode()
result = 31 * result + userLang.hashCode()
result = 31 * result + psnSignedIn.hashCode()
result = 31 * result + httpEnable.hashCode()
result = 31 * result + httpTimeoutAttempts
result = 31 * result + httpTimeoutSleepMs
result = 31 * result + httpReadEndAttempts
result = 31 * result + httpReadEndSleepMs
result = 31 * result + adhocAddr
result = 31 * result + logImports.hashCode()
result = 31 * result + logExports.hashCode()
result = 31 * result + logActiveShaders.hashCode()
result = 31 * result + logUniforms.hashCode()
result = 31 * result + colorSurfaceDebug.hashCode()
result = 31 * result + dumpElfs.hashCode()
result = 31 * result + validationLayer.hashCode()
result = 31 * result + textureCache.hashCode()
result = 31 * result + stretchDisplayArea.hashCode()
result = 31 * result + fullscreenHdResPixelPerfect.hashCode()
result = 31 * result + fileLoadingDelay
result = 31 * result + showLiveAreaScreen.hashCode()
result = 31 * result + showCompileShaders.hashCode()
result = 31 * result + checkForUpdates.hashCode()
result = 31 * result + checkForUpdatesMode
result = 31 * result + archiveLog.hashCode()
result = 31 * result + logCompatWarn.hashCode()
result = 31 * result + logLevel
result = 31 * result + performanceOverlay.hashCode()
result = 31 * result + performanceOverlayDetail
result = 31 * result + performanceOverlayPosition
result = 31 * result + screenshotFormat
return result
}
}
private fun defaultControllerBinds(): IntArray = intArrayOf(
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
)
private fun defaultControllerAxisBinds(): IntArray = intArrayOf(
0, 1, 2, 3, 4, 5
)
@@ -0,0 +1,91 @@
package org.vita3k.emulator.data
enum class FirmwareComponent {
Preinstalled,
Main,
FontPackage
}
data class FirmwareComponentPresence(
val preinstalled: Boolean = false,
val main: Boolean = false,
val fontPackage: Boolean = false
) {
val hasAnyInstalled: Boolean
get() = preinstalled || main || fontPackage
val missingComponents: Set<FirmwareComponent>
get() = buildSet {
if (!preinstalled) add(FirmwareComponent.Preinstalled)
if (!main) add(FirmwareComponent.Main)
if (!fontPackage) add(FirmwareComponent.FontPackage)
}
val missingComponentsInOrder: List<FirmwareComponent>
get() = buildList {
if (!preinstalled) add(FirmwareComponent.Preinstalled)
if (!main) add(FirmwareComponent.Main)
if (!fontPackage) add(FirmwareComponent.FontPackage)
}
val missingRequiredComponentsInOrder: List<FirmwareComponent>
get() = buildList {
if (!main) add(FirmwareComponent.Main)
if (!fontPackage) add(FirmwareComponent.FontPackage)
}
}
sealed interface FirmwareInstallState {
val components: FirmwareComponentPresence
val hasRequiredComponents: Boolean
val hasAnyInstalled: Boolean
get() = components.hasAnyInstalled
val hasMainFirmware: Boolean
get() = components.main
val hasFontPackage: Boolean
get() = components.fontPackage
val isComplete: Boolean
get() = this is Complete
data object Missing : FirmwareInstallState {
override val components = FirmwareComponentPresence()
override val hasRequiredComponents = false
}
data class Partial(
override val components: FirmwareComponentPresence,
override val hasRequiredComponents: Boolean
) : FirmwareInstallState
data class Complete(
override val components: FirmwareComponentPresence
) : FirmwareInstallState {
override val hasRequiredComponents = true
}
companion object {
private const val PREINSTALLED_MASK = 1 shl 0
private const val MAIN_MASK = 1 shl 1
private const val FONT_MASK = 1 shl 2
fun fromMask(mask: Int): FirmwareInstallState {
val components = FirmwareComponentPresence(
preinstalled = mask and PREINSTALLED_MASK != 0,
main = mask and MAIN_MASK != 0,
fontPackage = mask and FONT_MASK != 0
)
val hasRequiredComponents = components.main && components.fontPackage
return when {
!components.hasAnyInstalled -> Missing
components.missingComponents.isEmpty() -> Complete(components)
else -> Partial(components, hasRequiredComponents)
}
}
}
}
@@ -0,0 +1,45 @@
package org.vita3k.emulator.data
import androidx.annotation.StringRes
import org.vita3k.emulator.R
data class FirmwareLocale(
@StringRes val nameResId: Int,
val code: String
)
object FirmwareLinks {
const val PREINSTALL_URL = "https://bit.ly/4hlePsX"
const val FONT_PACKAGE_URL = "https://bit.ly/2P2rb0r"
val locales = listOf(
FirmwareLocale(R.string.settings_lang_japanese, "ja-jp"),
FirmwareLocale(R.string.settings_lang_english_us, "en-us"),
FirmwareLocale(R.string.settings_lang_french, "fr-fr"),
FirmwareLocale(R.string.settings_lang_spanish, "es-es"),
FirmwareLocale(R.string.settings_lang_german, "de-de"),
FirmwareLocale(R.string.settings_lang_italian, "it-it"),
FirmwareLocale(R.string.settings_lang_dutch, "nl-nl"),
FirmwareLocale(R.string.settings_lang_portuguese_pt, "pt-pt"),
FirmwareLocale(R.string.settings_lang_russian, "ru-ru"),
FirmwareLocale(R.string.settings_lang_korean, "ko-kr"),
FirmwareLocale(R.string.settings_lang_chinese_trad, "zh-hant-hk"),
FirmwareLocale(R.string.settings_lang_chinese_simp, "zh-hans-cn"),
FirmwareLocale(R.string.settings_lang_finnish, "fi-fi"),
FirmwareLocale(R.string.settings_lang_swedish, "sv-se"),
FirmwareLocale(R.string.settings_lang_danish, "da-dk"),
FirmwareLocale(R.string.settings_lang_norwegian, "no-no"),
FirmwareLocale(R.string.settings_lang_polish, "pl-pl"),
FirmwareLocale(R.string.settings_lang_portuguese_br, "pt-br"),
FirmwareLocale(R.string.settings_lang_english_gb, "en-gb"),
FirmwareLocale(R.string.settings_lang_turkish, "tr-tr")
)
fun coerceLocaleIndex(index: Int): Int =
index.coerceIn(0, locales.lastIndex)
fun firmwareDownloadUrl(index: Int): String {
val locale = locales[coerceLocaleIndex(index)]
return "https://www.playstation.com/${locale.code}/support/hardware/psvita/system-software/"
}
}
@@ -0,0 +1,5 @@
package org.vita3k.emulator.data
interface InstallCallback {
fun onProgress(percent: Int, status: String)
}
@@ -0,0 +1,45 @@
package org.vita3k.emulator.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.vita3k.emulator.NativeLib
internal object InstallRepository {
suspend fun installFirmware(path: String, onProgress: (Int, String) -> Unit): String =
withContext(Dispatchers.IO) {
NativeLib.installFirmware(path, progressCallback(onProgress))
}
suspend fun installPkg(path: String, zrif: String = "", onProgress: (Int, String) -> Unit): Boolean =
withContext(Dispatchers.IO) {
NativeLib.installPkg(path, zrif, progressCallback(onProgress))
}
suspend fun installArchive(path: String, forceReinstall: Boolean, onProgress: (Int, String) -> Unit): Boolean =
withContext(Dispatchers.IO) {
NativeLib.installArchive(path, progressCallback(onProgress), forceReinstall)
}
suspend fun copyLicense(path: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.copyLicense(path)
}
suspend fun createLicense(zrif: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.createLicense(zrif)
}
suspend fun findPkgZrif(pkgPath: String): String = withContext(Dispatchers.IO) {
NativeLib.findPkgZrif(pkgPath)
}
suspend fun convertRifToZrif(path: String): String = withContext(Dispatchers.IO) {
NativeLib.convertRifToZrif(path)
}
private fun progressCallback(onProgress: (Int, String) -> Unit) = object : InstallCallback {
override fun onProgress(percent: Int, status: String) {
onProgress(percent, status)
}
}
}
@@ -0,0 +1,13 @@
package org.vita3k.emulator.data
data class NativeAppInfo(
val titleId: String,
val title: String,
val category: String,
val appVer: String,
val iconPath: String,
val hasCustomConfig: Boolean,
val compatibility: Int,
val lastPlayed: Long,
val playtime: Long
)
@@ -0,0 +1,12 @@
package org.vita3k.emulator.data
data class NativeImeState(
val sceImeActive: Boolean,
val dialogActive: Boolean,
val text: String,
val preeditStart: Int,
val preeditLength: Int,
val caretIndex: Int,
val multiline: Boolean,
val enterLabel: String
)
@@ -0,0 +1,7 @@
package org.vita3k.emulator.data
data class NativeUser(
val id: String,
val name: String,
val active: Boolean
)
@@ -0,0 +1,31 @@
package org.vita3k.emulator.data
import androidx.annotation.StringRes
import org.vita3k.emulator.R
enum class RestartRequiredSetting(val nativeId: Int, @StringRes val labelResId: Int) {
CpuOpt(0, R.string.settings_cpu_opt),
BackendRenderer(1, R.string.settings_gpu_backend),
GraphicsDevice(2, R.string.settings_gpu_graphics_device),
CustomDriver(3, R.string.settings_gpu_custom_driver),
HighAccuracy(4, R.string.settings_gpu_accuracy),
ResolutionMultiplier(5, R.string.settings_gpu_resolution),
MemoryMapping(6, R.string.settings_gpu_memory_mapping),
AudioBackend(7, R.string.settings_audio_backend),
ValidationLayer(8, R.string.settings_debug_validation_layer);
companion object {
fun fromNative(values: IntArray): List<RestartRequiredSetting> {
if (values.isEmpty()) {
return emptyList()
}
val byId = entries.associateBy(RestartRequiredSetting::nativeId)
return buildList(values.size) {
values.forEach { nativeId ->
byId[nativeId]?.let(::add)
}
}
}
}
}
@@ -0,0 +1,178 @@
package org.vita3k.emulator.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.vita3k.emulator.NativeLib
internal data class SettingsSnapshot(
val config: EmulatorConfig,
val emulatorStoragePath: String,
val hasCustomConfig: Boolean,
val modulesList: List<Pair<String, Boolean>>,
val installedCustomDrivers: List<String>,
val showCustomDriverOptions: Boolean,
val supportedMemoryMappingMask: Int,
val customDriverLoadStatus: CustomDriverLoadStatus,
val availableCameras: List<String>,
val availableAdhocAddresses: List<Pair<String, String>>
)
enum class CustomDriverLoadStatus {
Default,
Loaded,
Fallback;
companion object {
fun fromNative(value: Int): CustomDriverLoadStatus = when (value) {
1 -> Loaded
2 -> Fallback
else -> Default
}
}
}
data class CustomDriverSupportInfo(
val supportedMemoryMappingMask: Int,
val loadStatus: CustomDriverLoadStatus
)
internal data class SettingsSaveResult(
val hasCustomConfig: Boolean,
val restartRequiredSettings: List<RestartRequiredSetting>
)
internal data class DeleteCustomConfigResult(
val snapshot: SettingsSnapshot,
val restartRequiredSettings: List<RestartRequiredSetting>
)
internal object SettingsRepository {
private val customDriverSupportInfoCache = mutableMapOf<String, CustomDriverSupportInfo>()
suspend fun load(titleId: String?): SettingsSnapshot = withContext(Dispatchers.IO) {
val hasCustomConfig = titleId != null && NativeLib.hasCustomConfig(titleId)
val loadedConfig = when {
titleId == null -> NativeLib.getGlobalConfig()
hasCustomConfig -> requireNotNull(NativeLib.getCustomConfig(titleId))
else -> NativeLib.getGlobalConfig()
}
buildSnapshot(loadedConfig, hasCustomConfig)
}
suspend fun save(routeTitleId: String?, effectiveTitleId: String?, config: EmulatorConfig): SettingsSaveResult {
val restartRequiredSettings = RestartRequiredSetting.fromNative(NativeLib.saveSettings(effectiveTitleId, config))
return SettingsSaveResult(
hasCustomConfig = routeTitleId != null && NativeLib.hasCustomConfig(routeTitleId),
restartRequiredSettings = restartRequiredSettings
)
}
suspend fun loadDefaults(): SettingsSnapshot = withContext(Dispatchers.IO) {
buildSnapshot(NativeLib.getDefaultConfig(), hasCustomConfig = false)
}
suspend fun setCurrentEmulatorPath(path: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.setCurrentEmulatorPath(path)
}
suspend fun deleteCustomConfig(titleId: String): DeleteCustomConfigResult {
val restartRequiredSettings = RestartRequiredSetting.fromNative(NativeLib.deleteCustomConfig(titleId))
return DeleteCustomConfigResult(
snapshot = buildSnapshot(NativeLib.getGlobalConfig(), hasCustomConfig = false),
restartRequiredSettings = restartRequiredSettings
)
}
suspend fun clearAllCustomConfigs(): Int = withContext(Dispatchers.IO) {
NativeLib.clearAllCustomConfigs()
}
suspend fun getInstalledCustomDrivers(): List<String> = withContext(Dispatchers.IO) {
NativeLib.getInstalledCustomDrivers().toList()
}
suspend fun installCustomDriver(path: String): String = withContext(Dispatchers.IO) {
NativeLib.installCustomDriver(path).also { installedName ->
if (installedName.isNotEmpty()) {
synchronized(customDriverSupportInfoCache) {
customDriverSupportInfoCache.remove(installedName)
}
}
}
}
suspend fun removeCustomDriver(driverName: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.removeCustomDriver(driverName).also { removed ->
if (removed) {
synchronized(customDriverSupportInfoCache) {
customDriverSupportInfoCache.remove(driverName)
}
}
}
}
suspend fun getSupportedMemoryMappingMask(customDriverName: String): Int = withContext(Dispatchers.IO) {
NativeLib.getSupportedMemoryMappingMask(customDriverName)
}
suspend fun getCustomDriverSupportInfo(customDriverName: String): CustomDriverSupportInfo = withContext(Dispatchers.IO) {
getCustomDriverSupportInfoInternal(customDriverName)
}
private fun getCustomDriverSupportInfoInternal(customDriverName: String): CustomDriverSupportInfo {
if (customDriverName.isNotEmpty()) {
synchronized(customDriverSupportInfoCache) {
customDriverSupportInfoCache[customDriverName]?.let { return it }
}
}
val raw = NativeLib.getCustomDriverSupportInfo(customDriverName)
val supportedMask = raw.getOrNull(0) ?: NativeLib.getSupportedMemoryMappingMask(customDriverName)
val loadStatus = CustomDriverLoadStatus.fromNative(raw.getOrNull(1) ?: 0)
val supportInfo = CustomDriverSupportInfo(
supportedMemoryMappingMask = supportedMask,
loadStatus = loadStatus
)
if (customDriverName.isNotEmpty()) {
synchronized(customDriverSupportInfoCache) {
customDriverSupportInfoCache.putIfAbsent(customDriverName, supportInfo)
}
}
return supportInfo
}
private fun buildSnapshot(config: EmulatorConfig, hasCustomConfig: Boolean): SettingsSnapshot {
val driverSupportInfo = getCustomDriverSupportInfoInternal(config.customDriverName)
return SettingsSnapshot(
config = config,
emulatorStoragePath = NativeLib.getCurrentEmulatorPath(),
hasCustomConfig = hasCustomConfig,
modulesList = parseModules(NativeLib.getModulesList(config.lleModules)),
installedCustomDrivers = NativeLib.getInstalledCustomDrivers().toList(),
showCustomDriverOptions = NativeLib.shouldShowCustomDriverOptions(),
supportedMemoryMappingMask = driverSupportInfo.supportedMemoryMappingMask,
customDriverLoadStatus = driverSupportInfo.loadStatus,
availableCameras = NativeLib.getAvailableCameras().toList(),
availableAdhocAddresses = parsePairs(NativeLib.getAvailableAdhocAddresses())
)
}
private fun parseModules(rawModules: Array<String>): List<Pair<String, Boolean>> = buildList {
var index = 0
while (index + 1 < rawModules.size) {
add(rawModules[index] to (rawModules[index + 1] == "true"))
index += 2
}
}
private fun parsePairs(rawPairs: Array<String>): List<Pair<String, String>> = buildList {
var index = 0
while (index + 1 < rawPairs.size) {
add(rawPairs[index] to rawPairs[index + 1])
index += 2
}
}
}
@@ -0,0 +1,45 @@
package org.vita3k.emulator.data
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
data class UiLanguageOption(
val tag: String,
val label: String
)
object UiLanguages {
private const val PREFS_NAME = "vita3k_frontend"
private const val PREF_UI_LANGUAGE = "ui_language"
val options: List<UiLanguageOption> = listOf(
UiLanguageOption("", "System Default"),
UiLanguageOption("en", "English")
)
fun currentTag(context: Context): String =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_UI_LANGUAGE, "") ?: ""
fun applyStored(context: Context) {
apply(currentTag(context))
}
fun applyAndPersist(context: Context, tag: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(PREF_UI_LANGUAGE, tag)
.apply()
apply(tag)
}
fun apply(tag: String) {
val locales = if (tag.isBlank()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
}
@@ -0,0 +1,31 @@
package org.vita3k.emulator.data
enum class UpdateCheckStatus {
Failed,
UpToDate,
UpdateAvailable,
CurrentBuildNewerThanLatest,
CustomBuildCanUpdate
}
data class UpdateInfo(
// Human-readable release name or tag.
val version: String = "",
// Numeric build identifier parsed from the release metadata.
val buildNumber: Long = 0L,
// Browser URL for the release page.
val releaseUrl: String = "",
// GitHub publication timestamp in ISO-8601 format.
val publishedAt: String = "",
// Release notes with updater metadata lines removed.
val notes: String = ""
)
data class UpdateCheckResult(
val status: UpdateCheckStatus,
val message: String,
// Parsed metadata for the latest available release.
val info: UpdateInfo = UpdateInfo(),
// Display version string for the installed build.
val currentDisplayVersion: String = ""
)
@@ -0,0 +1,24 @@
package org.vita3k.emulator.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.vita3k.emulator.NativeLib
internal object UserRepository {
suspend fun getUsers(): List<NativeUser> = withContext(Dispatchers.IO) {
NativeLib.getUsers().toList()
}
suspend fun createUser(name: String): String = withContext(Dispatchers.IO) {
NativeLib.createUser(name)
}
suspend fun activateUser(userId: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.activateUser(userId)
}
suspend fun deleteUser(userId: String): Boolean = withContext(Dispatchers.IO) {
NativeLib.deleteUser(userId)
}
}
@@ -5,9 +5,6 @@
package org.vita3k.emulator.overlay;
import org.libsdl.app.SDL;
import org.libsdl.app.SDLActivity;
import org.vita3k.emulator.Emulator;
import org.vita3k.emulator.R;
import android.app.Activity;
@@ -19,10 +16,8 @@ import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.preference.PreferenceManager;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.Display;
import android.view.MotionEvent;
import android.view.SurfaceView;
import android.view.View;
@@ -30,8 +25,6 @@ import android.view.View.OnTouchListener;
import java.util.HashSet;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
/**
* Draws the interactive input overlay on top of the
@@ -42,7 +35,11 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
// mirror what is in controller_dialog.cpp
public final static int OVERLAY_MASK_BASIC = 1;
public final static int OVERLAY_MASK_L2R2 = 2;
public final static int OVERLAY_MASK_TOUCH_SCREEN_SWITCH = 4;
public final static int OVERLAY_MASK_L3R3 = 4;
public final static int OVERLAY_MASK_TOUCH_SCREEN_SWITCH = 8;
public final static int OVERLAY_MASK_HIDE_TOGGLE = 16;
private final static int OVERLAY_MASK_UTILITY =
OVERLAY_MASK_TOUCH_SCREEN_SWITCH | OVERLAY_MASK_HIDE_TOGGLE;
// wait 10 seconds without inputs before hiding
private final static int OVERLAY_TIME_BEFORE_HIDE = 10;
@@ -50,27 +47,32 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
private final Set<InputOverlayDrawableButton> overlayButtons = new HashSet<>();
private final Set<InputOverlayDrawableDpad> overlayDpads = new HashSet<>();
private final Set<InputOverlayDrawableJoystick> overlayJoysticks = new HashSet<>();
private Rect mSurfacePosition = null;
private final Runnable mHideOverlayTicker = new Runnable() {
@Override
public void run() {
tick();
postDelayed(this, 1000);
}
};
private int mOverlayMask = 0;
private boolean mIsInEditMode = false;
private boolean mAutoHideEnabled = true;
private boolean mAllowVirtualController = true;
private boolean mControllerAttached = false;
private InputOverlayDrawableButton mButtonBeingConfigured;
private InputOverlayDrawableDpad mDpadBeingConfigured;
private InputOverlayDrawableJoystick mJoystickBeingConfigured;
private static float mGlobalScale = 1.0f;
private static int mGlobalOpacity = 100;
private Timer mTimer;
private String mLayoutProfileId = "";
// last Time the screen was touched
private long mlastTouchTime;
// is the overlay hidden because we didn't used it for long enough ?
private boolean mShowingOverlay = true;
// hide overlay manually with touch overlay
private static boolean hide_overlay = false;
private final SharedPreferences mPreferences;
// hide overlay manually while keeping the touch utility buttons available
private boolean mHideOverlayButtons = false;
/**
* Resizes a {@link Bitmap} by a given scale factor
@@ -103,9 +105,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
{
super(context/*, attrs*/);
mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext());
if (!mPreferences.getBoolean("OverlayInit", false))
defaultOverlay();
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
// Set the on touch listener.
// Do not register the overlay as a touch listener
@@ -118,39 +118,44 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
// Request focus for the overlay so it has priority on presses.
requestFocus();
/*SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
sPrefsEditor.putBoolean("OverlayInit", true);
sPrefsEditor.apply();*/
refreshControls();
mTimer = new Timer();
// call tick every second to check if we should stop displaying the overlay
mTimer.scheduleAtFixedRate(new TimerTask()
{
@Override
public void run()
{
Emulator emu = (Emulator) SDL.getContext();
emu.getmOverlay().tick();
}
}, 1000, 1000);
resetHideTimer();
}
private void resetHideTimer()
{
if (!mShowingOverlay)
private void startHideTimer() {
removeCallbacks(mHideOverlayTicker);
postDelayed(mHideOverlayTicker, 1000);
}
private void stopHideTimer() {
removeCallbacks(mHideOverlayTicker);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
startHideTimer();
updateVirtualControllerState();
}
@Override
protected void onDetachedFromWindow() {
stopHideTimer();
releaseAllInputs();
detachVirtualController();
super.onDetachedFromWindow();
}
private void resetHideTimer(){
if(!mShowingOverlay)
invalidate();
mShowingOverlay = true;
mlastTouchTime = System.currentTimeMillis();
}
public void tick()
{
if (mOverlayMask == 0 || !mShowingOverlay || isInEditMode())
public void tick(){
if(mOverlayMask == 0 || !mShowingOverlay || isInEditMode() || !mAutoHideEnabled)
return;
long current_time = System.currentTimeMillis();
@@ -160,42 +165,46 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
}
}
public void setState(int overlay_mask)
{
boolean was_showing = mOverlayMask != 0;
if (hide_overlay && mOverlayMask != 4)
{
mOverlayMask = 4;
refreshControls();
invalidate();
}
else if (mOverlayMask != overlay_mask)
{
public void setState(int overlay_mask){
if(mOverlayMask != overlay_mask){
mOverlayMask = overlay_mask;
invalidate();
}
resetHideTimer();
updateVirtualControllerState();
}
boolean is_showing = overlay_mask != 0;
if (is_showing == was_showing)
return;
public int getOverlayMask() {
return mOverlayMask;
}
if (is_showing)
attachController();
else
detachController();
public void releaseAllInputs() {
for (InputOverlayDrawableButton button : overlayButtons) {
if ((button.getRole() & OVERLAY_MASK_UTILITY) == 0) {
setButton(button.getControl(), false);
}
button.setPressedState(false);
button.setTrackId(-1);
}
for (InputOverlayDrawableDpad dpad : overlayDpads) {
for (int i = 0; i < 4; i++) {
setButton(dpad.getControl(i), false);
}
dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
dpad.setTrackId(-1);
}
for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
setAxis(joystick.getXControl(), (short)0);
setAxis(joystick.getYControl(), (short)0);
joystick.release();
}
invalidate();
}
public void setSurfacePosition(Rect rect)
{
mSurfacePosition = rect;
}
@Override
public void draw(Canvas canvas)
{
super.draw(canvas);
@@ -207,17 +216,17 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
{
int role = button.getRole();
if (hide_overlay) {
if (role == OVERLAY_MASK_TOUCH_SCREEN_SWITCH)
button.draw(canvas);
else
if (mHideOverlayButtons) {
if ((role & OVERLAY_MASK_UTILITY) == 0 || (role & mOverlayMask) == 0)
continue;
} else if ((role & mOverlayMask) == 0) {
continue;
} else if ((role & mOverlayMask) != 0) {
button.draw(canvas);
}
}
if (!hide_overlay)
button.draw(canvas);
}
if (!mHideOverlayButtons)
{
for (InputOverlayDrawableDpad dpad : overlayDpads)
{
@@ -237,6 +246,9 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
if (mOverlayMask == 0)
return false;
if(!mAllowVirtualController && !isInEditMode())
return false;
resetHideTimer();
if (isInEditMode())
@@ -253,12 +265,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
for (InputOverlayDrawableButton button : overlayButtons)
{
int btn_role = button.getRole();
int legacy_id = button.getLegacyId();
int buttonRole = button.getRole();
int legacyId = button.getLegacyId();
if (hide_overlay && btn_role != OVERLAY_MASK_TOUCH_SCREEN_SWITCH)
if (mHideOverlayButtons &&
(((buttonRole & OVERLAY_MASK_UTILITY) == 0) || (buttonRole & mOverlayMask) == 0))
continue;
else if ((btn_role & mOverlayMask) == 0)
else if ((buttonRole & mOverlayMask) == 0)
continue;
// Determine the button state to apply based on the MotionEvent action flag.
@@ -274,10 +287,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
button.setTrackId(event.getPointerId(pointerIndex));
concerned = true;
if (legacy_id == ButtonType.BUTTON_TOUCH_SWITCH)
if (legacyId == ButtonType.BUTTON_TOUCH_SWITCH)
setTouchState(button.getPressed());
else if (legacy_id == ButtonType.BUTTON_TOUCH_HIDE)
hide_overlay = !hide_overlay;
else if (legacyId == ButtonType.BUTTON_TOUCH_HIDE)
mHideOverlayButtons = !mHideOverlayButtons;
else
setButton(button.getControl(), true);
}
@@ -289,15 +302,9 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
{
button.setPressedState(false);
if (legacy_id != ButtonType.BUTTON_TOUCH_SWITCH)
if (legacyId != ButtonType.BUTTON_TOUCH_SWITCH &&
legacyId != ButtonType.BUTTON_TOUCH_HIDE)
setButton(button.getControl(), false);
else if (legacy_id == ButtonType.BUTTON_TOUCH_HIDE && hide_overlay)
if (mOverlayMask != 4) {
mOverlayMask = 4;
detachController();
refreshControls();
attachController();
}
button.setTrackId(-1);
concerned = true;
@@ -306,7 +313,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
}
}
if (!hide_overlay)
if (!mHideOverlayButtons)
{
for (InputOverlayDrawableDpad dpad : overlayDpads)
{
@@ -517,10 +524,6 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
return intersect;
}
public void onDestroy()
{
}
private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left,
boolean right)
{
@@ -557,85 +560,83 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
{
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_cross,
R.drawable.button_cross_pressed, ButtonType.BUTTON_CROSS, ControlId.a,
orientation, OVERLAY_MASK_BASIC));
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_circle,
R.drawable.button_circle_pressed, ButtonType.BUTTON_CIRCLE, ControlId.b,
orientation, OVERLAY_MASK_BASIC));
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_square,
R.drawable.button_square_pressed, ButtonType.BUTTON_SQUARE, ControlId.x,
orientation, OVERLAY_MASK_BASIC));
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_triangle,
R.drawable.button_triangle_pressed, ButtonType.BUTTON_TRIANGLE, ControlId.y,
orientation, OVERLAY_MASK_BASIC));
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start,
R.drawable.button_start_pressed, ButtonType.BUTTON_START,
ControlId.start, orientation, OVERLAY_MASK_BASIC));
ControlId.start, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_ps,
R.drawable.button_ps_pressed, ButtonType.BUTTON_PS,
ControlId.guide, orientation, OVERLAY_MASK_BASIC));
ControlId.guide, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select,
R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT,
ControlId.select, orientation, OVERLAY_MASK_BASIC));
ControlId.select, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l,
R.drawable.button_l_pressed, ButtonType.TRIGGER_L,
ControlId.l1, orientation, OVERLAY_MASK_BASIC));
ControlId.l1, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r,
R.drawable.button_r_pressed, ButtonType.TRIGGER_R,
ControlId.r1, orientation, OVERLAY_MASK_BASIC));
ControlId.r1, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l2,
R.drawable.button_l2_pressed, ButtonType.TRIGGER_L2,
ControlId.l2, orientation, OVERLAY_MASK_L2R2));
ControlId.l2, orientation, OVERLAY_MASK_L2R2, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r2,
R.drawable.button_r2_pressed, ButtonType.TRIGGER_R2,
ControlId.r2, orientation, OVERLAY_MASK_L2R2));
ControlId.r2, orientation, OVERLAY_MASK_L2R2, mLayoutProfileId));
// L3 and R3
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l3,
R.drawable.button_l3_pressed, ButtonType.TRIGGER_L3,
ControlId.l3, orientation, OVERLAY_MASK_L2R2));
ControlId.l3, orientation, OVERLAY_MASK_L3R3, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r3,
R.drawable.button_r3_pressed, ButtonType.TRIGGER_R3,
ControlId.r3, orientation, OVERLAY_MASK_L2R2));
ControlId.r3, orientation, OVERLAY_MASK_L3R3, mLayoutProfileId));
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_touch_f,
R.drawable.button_touch_b, ButtonType.BUTTON_TOUCH_SWITCH,
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH));
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH, mLayoutProfileId));
// show hide button
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_hide,
R.drawable.button_hide_pressed, ButtonType.BUTTON_TOUCH_HIDE,
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH));
ControlId.touch, orientation, OVERLAY_MASK_HIDE_TOGGLE, mLayoutProfileId));
overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad_idle,
R.drawable.dpad_up,
R.drawable.dpad_up_left,
ButtonType.DPAD_UP, ControlId.dup, ControlId.ddown,
ControlId.dleft, ControlId.dright, orientation));
ControlId.dleft, ControlId.dright, orientation, mLayoutProfileId));
overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.joystick_range,
R.drawable.joystick, R.drawable.joystick_pressed,
ButtonType.STICK_LEFT, ControlId.axis_left_x,
ControlId.axis_left_y, orientation));
ControlId.axis_left_y, orientation, mLayoutProfileId));
overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.joystick_range,
R.drawable.joystick, R.drawable.joystick_pressed,
ButtonType.STICK_RIGHT, ControlId.axis_right_x,
ControlId.axis_right_y, orientation));
ControlId.axis_right_y, orientation, mLayoutProfileId));
}
public void refreshControls()
{
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
// Remove all the overlay buttons from the HashSet.
overlayButtons.clear();
overlayDpads.clear();
@@ -649,7 +650,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
public void resetButtonPlacement()
{
vitaDefaultOverlay();
OverlayLayoutStore.resetToDefaults(getContext(), mLayoutProfileId);
refreshControls();
}
@@ -670,28 +671,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
private void saveControlPosition(int sharedPrefsId, int x, int y,
String orientation)
{
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext());
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(getContext());
SharedPreferences.Editor sPrefsEditor = sPrefs.edit();
sPrefsEditor.putFloat(getXKey(sharedPrefsId, orientation), x);
sPrefsEditor.putFloat(getYKey(sharedPrefsId, orientation), y);
sPrefsEditor.putFloat(OverlayLayoutStore.positionXKey(sharedPrefsId, orientation, mLayoutProfileId), x);
sPrefsEditor.putFloat(OverlayLayoutStore.positionYKey(sharedPrefsId, orientation, mLayoutProfileId), y);
sPrefsEditor.apply();
}
private static String getKey(int sharedPrefsId, String orientation, String suffix)
{
return sharedPrefsId + orientation + suffix;
}
private static String getXKey(int sharedPrefsId, String orientation)
{
return getKey(sharedPrefsId, orientation, "-X");
}
private static String getYKey(int sharedPrefsId, String orientation)
{
return getKey(sharedPrefsId, orientation, "-Y");
}
/**
* Initializes an InputOverlayDrawableButton, given by resId, with all of the
* parameters set for it to be properly shown on the InputOverlay.
@@ -724,13 +710,14 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
* @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set.
*/
private static InputOverlayDrawableButton initializeOverlayButton(Context context,
int defaultResId, int pressedResId, int legacyId, int control, String orientation, int role)
int defaultResId, int pressedResId, int legacyId, int control, String orientation, int role,
String layoutProfileId)
{
// Resources handle for fetching the initial Drawable resource.
final Resources res = context.getResources();
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton.
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
// Decide scale based on button ID and user preference
float scale = 0.15f;
@@ -760,8 +747,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
// The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
// These were set in the input overlay configuration menu.
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
int width = overlayDrawable.getWidth();
int height = overlayDrawable.getHeight();
@@ -800,13 +787,14 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
int downControl,
int leftControl,
int rightControl,
String orientation)
String orientation,
String layoutProfileId)
{
// Resources handle for fetching the initial Drawable resource.
final Resources res = context.getResources();
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad.
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
// Decide scale based on button ID and user preference
float scale = 0.35f;
@@ -829,8 +817,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
// The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay.
// These were set in the input overlay configuration menu.
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
int width = overlayDrawable.getWidth();
int height = overlayDrawable.getHeight();
@@ -860,13 +848,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
*/
private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context,
int resOuter, int defaultResInner, int pressedResInner, int legacyId, int xControl,
int yControl, String orientation)
int yControl, String orientation, String layoutProfileId)
{
// Resources handle for fetching the initial Drawable resource.
final Resources res = context.getResources();
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
// Decide scale based on user preference
float scale = 0.275f;
@@ -880,8 +868,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
// The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
// These were set in the input overlay configuration menu.
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
// Decide inner scale based on joystick ID
float innerScale = 1.375f;
@@ -907,6 +895,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
public void setIsInEditMode(boolean isInEditMode)
{
mIsInEditMode = isInEditMode;
if (mIsInEditMode) {
mHideOverlayButtons = false;
mShowingOverlay = true;
}
}
public boolean isInEditMode()
@@ -914,113 +906,62 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
return mIsInEditMode;
}
private void defaultOverlay()
public void setAutoHideEnabled(boolean autoHideEnabled)
{
if (!mPreferences.getBoolean("OverlayInit", false))
{
vitaDefaultOverlay();
mAutoHideEnabled = autoHideEnabled;
if(!mAutoHideEnabled){
mShowingOverlay = true;
invalidate();
} else {
resetHideTimer();
}
}
private void vitaDefaultOverlay()
public void setAllowVirtualController(boolean allowVirtualController)
{
SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
if(mAllowVirtualController == allowVirtualController)
return;
// Get screen size
Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
DisplayMetrics outMetrics = new DisplayMetrics();
display.getMetrics(outMetrics);
float maxX = outMetrics.heightPixels;
float maxY = outMetrics.widthPixels;
// Height and width changes depending on orientation. Use the larger value for maxX.
if (maxY > maxX)
{
float tmp = maxX;
maxX = maxY;
maxY = tmp;
mAllowVirtualController = allowVirtualController;
updateVirtualControllerState();
}
Resources res = getResources();
// Each value is a percent from max X/Y stored as an int. Have to bring that value down
// to a decimal before multiplying by MAX X/Y.
sPrefsEditor.putFloat(ButtonType.BUTTON_CROSS + "-X",
(((float) res.getInteger(R.integer.BUTTON_CROSS_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_CROSS + "-Y",
(((float) res.getInteger(R.integer.BUTTON_CROSS_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_CIRCLE + "-X",
(((float) res.getInteger(R.integer.BUTTON_CIRCLE_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_CIRCLE + "-Y",
(((float) res.getInteger(R.integer.BUTTON_CIRCLE_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_SQUARE + "-X",
(((float) res.getInteger(R.integer.BUTTON_SQUARE_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_SQUARE + "-Y",
(((float) res.getInteger(R.integer.BUTTON_SQUARE_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_TRIANGLE + "-X",
(((float) res.getInteger(R.integer.BUTTON_TRIANGLE_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_TRIANGLE + "-Y",
(((float) res.getInteger(R.integer.BUTTON_TRIANGLE_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X",
(((float) res.getInteger(R.integer.BUTTON_SELECT_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y",
(((float) res.getInteger(R.integer.BUTTON_SELECT_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X",
(((float) res.getInteger(R.integer.BUTTON_START_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y",
(((float) res.getInteger(R.integer.BUTTON_START_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.BUTTON_PS + "-X",
(((float) res.getInteger(R.integer.BUTTON_PS_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_PS + "-Y",
(((float) res.getInteger(R.integer.BUTTON_PS_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X",
(((float) res.getInteger(R.integer.DPAD_UP_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y",
(((float) res.getInteger(R.integer.DPAD_UP_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X",
(((float) res.getInteger(R.integer.STICK_LEFT_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y",
(((float) res.getInteger(R.integer.STICK_LEFT_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.STICK_RIGHT + "-X",
(((float) res.getInteger(R.integer.STICK_RIGHT_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.STICK_RIGHT + "-Y",
(((float) res.getInteger(R.integer.STICK_RIGHT_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X",
(((float) res.getInteger(R.integer.TRIGGER_L_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_L_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X",
(((float) res.getInteger(R.integer.TRIGGER_R_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_R_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.TRIGGER_L2 + "-X",
(((float) res.getInteger(R.integer.TRIGGER_L2_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_L2 + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_L2_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R2 + "-X",
(((float) res.getInteger(R.integer.TRIGGER_R2_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R2 + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_R2_Y) / 1000) * maxY));
public void setLayoutProfileId(String layoutProfileId)
{
String normalizedProfileId = OverlayLayoutStore.normalizeProfileId(layoutProfileId);
if(mLayoutProfileId.equals(normalizedProfileId))
return;
sPrefsEditor.putFloat(ButtonType.TRIGGER_L3 + "-X",
(((float) res.getInteger(R.integer.TRIGGER_L3_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_L3 + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_L3_Y) / 1000) * maxY));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R3 + "-X",
(((float) res.getInteger(R.integer.TRIGGER_R3_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.TRIGGER_R3 + "-Y",
(((float) res.getInteger(R.integer.TRIGGER_R3_Y) / 1000) * maxY));
mLayoutProfileId = normalizedProfileId;
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
refreshControls();
}
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_SWITCH + "-X",
(((float) res.getInteger(R.integer.BUTTON_TOUCH_SWITCH_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_SWITCH + "-Y",
(((float) res.getInteger(R.integer.BUTTON_TOUCH_SWITCH_Y) / 1000) * maxY));
public void rebindController()
{
releaseAllInputs();
detachVirtualController();
updateVirtualControllerState();
}
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_HIDE + "-X",
(((float) res.getInteger(R.integer.BUTTON_TOUCH_HIDE_X) / 1000) * maxX));
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_HIDE + "-Y",
(((float) res.getInteger(R.integer.BUTTON_TOUCH_HIDE_Y) / 1000) * maxY));
private void updateVirtualControllerState()
{
boolean shouldAttach = mAllowVirtualController && mOverlayMask != 0 && getWindowToken() != null;
if (shouldAttach == mControllerAttached)
return;
// We want to commit right away, otherwise the overlay could load before this is saved.
sPrefsEditor.commit();
if (shouldAttach) {
attachController();
mControllerAttached = true;
} else {
detachVirtualController();
}
}
private void detachVirtualController()
{
detachController();
mControllerAttached = false;
}
public native void attachController();
@@ -1075,7 +1016,6 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
public static final int r2 = -5;
// button to switch between front and back touch
public static final int hide = 1023;
public static final int touch = 1024;
public static final int axis_left_x = 0;
@@ -331,4 +331,19 @@ public final class InputOverlayDrawableJoystick
{
return trackId;
}
public void release()
{
mPressedState = false;
mCurrentX = mCurrentY = 0.0f;
mJoystickX = mJoystickY = 0.0f;
mOuterBitmap.setAlpha(mOpacity);
mBoundsBoxBitmap.setAlpha(0);
setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
mOrigBounds.bottom));
setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
mOrigBounds.bottom));
SetInnerBounds();
trackId = -1;
}
}
@@ -0,0 +1,117 @@
package org.vita3k.emulator.overlay
import android.content.Context
val DEFAULT_OVERLAY_MASK: Int =
InputOverlay.OVERLAY_MASK_BASIC or InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH
data class OverlayConfig(
val overlayMask: Int = DEFAULT_OVERLAY_MASK,
val overlayScale: Int = 100,
val overlayOpacity: Int = 100,
val hideOverlayWhenControllerConnected: Boolean = true
) {
val controlsVisible: Boolean
get() = overlayMask != 0
val l2r2Visible: Boolean
get() = overlayMask and InputOverlay.OVERLAY_MASK_L2R2 != 0
val l3r3Visible: Boolean
get() = overlayMask and InputOverlay.OVERLAY_MASK_L3R3 != 0
val touchSwitchVisible: Boolean
get() = overlayMask and InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH != 0
val hideToggleVisible: Boolean
get() = overlayMask and InputOverlay.OVERLAY_MASK_HIDE_TOGGLE != 0
fun activeMask(): Int = if (overlayMask == 0) DEFAULT_OVERLAY_MASK else overlayMask
fun withControlsVisible(visible: Boolean): OverlayConfig {
return copy(overlayMask = if (visible) activeMask() else 0).normalized()
}
fun withL2R2Visible(visible: Boolean): OverlayConfig {
val baseMask = activeMask()
val updatedMask = if (visible) {
baseMask or InputOverlay.OVERLAY_MASK_L2R2
} else {
baseMask and InputOverlay.OVERLAY_MASK_L2R2.inv()
}
return copy(overlayMask = updatedMask).normalized()
}
fun withL3R3Visible(visible: Boolean): OverlayConfig {
val baseMask = activeMask()
val updatedMask = if (visible) {
baseMask or InputOverlay.OVERLAY_MASK_L3R3
} else {
baseMask and InputOverlay.OVERLAY_MASK_L3R3.inv()
}
return copy(overlayMask = updatedMask).normalized()
}
fun withTouchSwitchVisible(visible: Boolean): OverlayConfig {
val baseMask = activeMask()
val updatedMask = if (visible) {
baseMask or InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH
} else {
baseMask and InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH.inv()
}
return copy(overlayMask = updatedMask).normalized()
}
fun withHideToggleVisible(visible: Boolean): OverlayConfig {
val baseMask = activeMask()
val updatedMask = if (visible) {
baseMask or InputOverlay.OVERLAY_MASK_HIDE_TOGGLE
} else {
baseMask and InputOverlay.OVERLAY_MASK_HIDE_TOGGLE.inv()
}
return copy(overlayMask = updatedMask).normalized()
}
fun normalized(): OverlayConfig {
val knownMask = InputOverlay.OVERLAY_MASK_BASIC or
InputOverlay.OVERLAY_MASK_L2R2 or
InputOverlay.OVERLAY_MASK_L3R3 or
InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH or
InputOverlay.OVERLAY_MASK_HIDE_TOGGLE
return copy(
overlayMask = overlayMask.coerceAtLeast(0) and knownMask,
overlayScale = overlayScale.coerceIn(50, 150),
overlayOpacity = overlayOpacity.coerceIn(20, 100)
)
}
}
object OverlayConfigStore {
private const val PREFS_NAME = "emulation_session"
private const val KEY_OVERLAY_MASK = "overlay_mask"
private const val KEY_OVERLAY_SCALE = "overlay_scale"
private const val KEY_OVERLAY_OPACITY = "overlay_opacity"
private const val KEY_HIDE_ON_CONTROLLER = "hide_overlay_on_controller"
fun load(context: Context): OverlayConfig {
val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return OverlayConfig(
overlayMask = prefs.getInt(KEY_OVERLAY_MASK, DEFAULT_OVERLAY_MASK),
overlayScale = prefs.getInt(KEY_OVERLAY_SCALE, 100),
overlayOpacity = prefs.getInt(KEY_OVERLAY_OPACITY, 100),
hideOverlayWhenControllerConnected = prefs.getBoolean(KEY_HIDE_ON_CONTROLLER, true)
).normalized()
}
fun save(context: Context, config: OverlayConfig) {
val normalized = config.normalized()
context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putInt(KEY_OVERLAY_MASK, normalized.overlayMask)
.putInt(KEY_OVERLAY_SCALE, normalized.overlayScale)
.putInt(KEY_OVERLAY_OPACITY, normalized.overlayOpacity)
.putBoolean(KEY_HIDE_ON_CONTROLLER, normalized.hideOverlayWhenControllerConnected)
.apply()
}
}
@@ -0,0 +1,124 @@
package org.vita3k.emulator.overlay
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.util.DisplayMetrics
import org.vita3k.emulator.R
object OverlayLayoutStore {
private const val KEY_OVERLAY_INIT = "OverlayInit"
private data class OverlayPositionDefaults(
val buttonType: Int,
val xResId: Int,
val yResId: Int
)
private val defaultEntries = listOf(
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_CROSS, R.integer.BUTTON_CROSS_X, R.integer.BUTTON_CROSS_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_CIRCLE, R.integer.BUTTON_CIRCLE_X, R.integer.BUTTON_CIRCLE_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_SQUARE, R.integer.BUTTON_SQUARE_X, R.integer.BUTTON_SQUARE_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TRIANGLE, R.integer.BUTTON_TRIANGLE_X, R.integer.BUTTON_TRIANGLE_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_SELECT, R.integer.BUTTON_SELECT_X, R.integer.BUTTON_SELECT_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_START, R.integer.BUTTON_START_X, R.integer.BUTTON_START_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_PS, R.integer.BUTTON_PS_X, R.integer.BUTTON_PS_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.DPAD_UP, R.integer.DPAD_UP_X, R.integer.DPAD_UP_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.STICK_LEFT, R.integer.STICK_LEFT_X, R.integer.STICK_LEFT_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.STICK_RIGHT, R.integer.STICK_RIGHT_X, R.integer.STICK_RIGHT_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L, R.integer.TRIGGER_L_X, R.integer.TRIGGER_L_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R, R.integer.TRIGGER_R_X, R.integer.TRIGGER_R_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L2, R.integer.TRIGGER_L2_X, R.integer.TRIGGER_L2_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R2, R.integer.TRIGGER_R2_X, R.integer.TRIGGER_R2_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L3, R.integer.TRIGGER_L3_X, R.integer.TRIGGER_L3_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R3, R.integer.TRIGGER_R3_X, R.integer.TRIGGER_R3_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TOUCH_HIDE, R.integer.BUTTON_TOUCH_HIDE_X, R.integer.BUTTON_TOUCH_HIDE_Y),
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TOUCH_SWITCH, R.integer.BUTTON_TOUCH_SWITCH_X, R.integer.BUTTON_TOUCH_SWITCH_Y)
)
@JvmStatic
fun ensureInitialized(context: Context, profileId: String? = null) {
val prefs = preferences(context)
val initKey = initKey(profileId)
if (!prefs.getBoolean(initKey, false)) {
resetToDefaults(context, profileId)
}
}
@JvmStatic
fun resetToDefaults(context: Context, profileId: String? = null) {
val prefs = preferences(context)
val editor = prefs.edit()
val resources = context.resources
val (maxX, maxY) = resolveOverlayBounds(context)
defaultEntries.forEach { entry ->
editor.putFloat(
positionXKey(entry.buttonType, "", profileId),
(resources.getInteger(entry.xResId) / 1000f) * maxX
)
editor.putFloat(
positionYKey(entry.buttonType, "", profileId),
(resources.getInteger(entry.yResId) / 1000f) * maxY
)
}
editor.putBoolean(initKey(profileId), true)
editor.apply()
}
@JvmStatic
fun positionXKey(buttonType: Int, orientation: String, profileId: String? = null): String =
scopedKey("$buttonType$orientation-X", profileId)
@JvmStatic
fun positionYKey(buttonType: Int, orientation: String, profileId: String? = null): String =
scopedKey("$buttonType$orientation-Y", profileId)
@JvmStatic
fun initKey(profileId: String? = null): String = scopedKey(KEY_OVERLAY_INIT, profileId)
@JvmStatic
fun normalizeProfileId(profileId: String?): String = profileId.orEmpty().trim()
@JvmStatic
fun preferences(context: Context): SharedPreferences =
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
private fun scopedKey(rawKey: String, profileId: String?): String {
val normalizedProfile = normalizeProfileId(profileId)
return if (normalizedProfile.isEmpty()) rawKey else "overlay:$normalizedProfile:$rawKey"
}
private fun resolveOverlayBounds(context: Context): Pair<Float, Float> {
val activity = context as? Activity
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bounds = activity.windowManager.currentWindowMetrics.bounds
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
return if (width >= height) {
width to height
} else {
height to width
}
}
val metrics = DisplayMetrics()
if (activity != null) {
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.getMetrics(metrics)
} else {
metrics.setTo(context.resources.displayMetrics)
}
var maxX = metrics.heightPixels.toFloat()
var maxY = metrics.widthPixels.toFloat()
if (maxY > maxX) {
val tmp = maxX
maxX = maxY
maxY = tmp
}
return maxX to maxY
}
}
@@ -0,0 +1,42 @@
package org.vita3k.emulator.ui
import android.content.Context
import android.hardware.input.InputManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.vita3k.emulator.ConnectedGamepad
import org.vita3k.emulator.InputDeviceUtils
const val CUSTOM_DRIVER_DOWNLOAD_URL = "https://github.com/K11MCH1/AdrenoToolsDrivers/releases/"
@Composable
fun rememberConnectedGamepads(context: Context): List<ConnectedGamepad> {
var connectedGamepads by remember { mutableStateOf(InputDeviceUtils.getPhysicalGamepads()) }
DisposableEffect(context) {
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager
val listener = object : InputManager.InputDeviceListener {
private fun refresh() {
connectedGamepads = InputDeviceUtils.getPhysicalGamepads()
}
override fun onInputDeviceAdded(deviceId: Int) = refresh()
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
override fun onInputDeviceChanged(deviceId: Int) = refresh()
}
connectedGamepads = InputDeviceUtils.getPhysicalGamepads()
inputManager?.registerInputDeviceListener(listener, null)
onDispose {
inputManager?.unregisterInputDeviceListener(listener)
}
}
return connectedGamepads
}
@@ -0,0 +1,48 @@
package org.vita3k.emulator.ui.components
import android.text.method.LinkMovementMethod
import android.view.Gravity
import android.view.View
import android.widget.TextView
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
@Composable
fun HtmlText(
html: String,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodyMedium,
textColor: Color = MaterialTheme.colorScheme.onSurface,
linkColor: Color = MaterialTheme.colorScheme.primary,
gravity: Int = Gravity.START
) {
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
highlightColor = android.graphics.Color.TRANSPARENT
setBackgroundColor(android.graphics.Color.TRANSPARENT)
linksClickable = true
}
},
update = { view ->
view.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
view.setTextColor(textColor.toArgb())
view.setLinkTextColor(linkColor.toArgb())
view.textSize = textStyle.fontSize.value
view.gravity = gravity
view.textAlignment = if (gravity == Gravity.CENTER) {
View.TEXT_ALIGNMENT_CENTER
} else {
View.TEXT_ALIGNMENT_VIEW_START
}
}
)
}
@@ -0,0 +1,105 @@
package org.vita3k.emulator.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import kotlin.math.roundToInt
@Composable
internal fun OverlayEditorPalette(
onDone: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(
modifier = modifier.fillMaxSize()
) {
var offsetX by rememberSaveable { mutableIntStateOf(0) }
var offsetY by rememberSaveable { mutableIntStateOf(0) }
var panelSize by remember { mutableStateOf(IntSize.Zero) }
val maxOffsetX = ((constraints.maxWidth - panelSize.width) / 2).coerceAtLeast(0)
val maxOffsetY = ((constraints.maxHeight - panelSize.height) / 2).coerceAtLeast(0)
Surface(
modifier = Modifier
.align(Alignment.Center)
.offset { IntOffset(offsetX, offsetY) }
.widthIn(min = 220.dp, max = 280.dp)
.onSizeChanged { panelSize = it }
.pointerInput(maxOffsetX, maxOffsetY) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount.x.roundToInt())
.coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + dragAmount.y.roundToInt())
.coerceIn(-maxOffsetY, maxOffsetY)
}
},
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = onReset,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Restore, contentDescription = null)
Text(
text = stringResource(R.string.action_reset),
modifier = Modifier.padding(start = 6.dp)
)
}
FilledTonalButton(
onClick = onDone,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Done, contentDescription = null)
Text(
text = stringResource(R.string.action_done),
modifier = Modifier.padding(start = 6.dp)
)
}
}
}
}
}
@@ -0,0 +1,349 @@
package org.vita3k.emulator.ui.navigation
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.material3.CircularProgressIndicator
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.vita3k.emulator.MainActivity
import org.vita3k.emulator.data.AppStorage
import org.vita3k.emulator.data.AppInfo
import org.vita3k.emulator.ui.screens.AppInfoSheet
import org.vita3k.emulator.ui.screens.AppsListScreen
import org.vita3k.emulator.ui.screens.InitialSetupScreen
import org.vita3k.emulator.ui.screens.ArchiveInstallSourceDialog
import org.vita3k.emulator.ui.screens.InstallBottomSheet
import org.vita3k.emulator.ui.screens.InstallProgressDialog
import org.vita3k.emulator.ui.screens.InstallResultDialog
import org.vita3k.emulator.ui.screens.LicenseSourceDialog
import org.vita3k.emulator.ui.screens.UserManagementScreen
import org.vita3k.emulator.ui.screens.settings.SettingsRoute
import org.vita3k.emulator.ui.viewmodel.AppsListViewModel
import org.vita3k.emulator.ui.viewmodel.InstallViewModel
import org.vita3k.emulator.ui.viewmodel.InstallType
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
private const val ROUTE_INITIAL_SETUP = "initial_setup"
private const val ROUTE_INITIAL_SETUP_MANUAL = "initial_setup/manual"
private const val ROUTE_APPS_LIST = "apps_list"
private const val ROUTE_SETTINGS = "settings"
private const val ROUTE_USER_MANAGEMENT = "users"
private const val ROUTE_CUSTOM_CONFIG = "settings/custom/{titleId}?appName={appName}"
private const val ARG_TITLE_ID = "titleId"
private const val ARG_APP_NAME = "appName"
private val FIRMWARE_EXTENSIONS = setOf(".pup")
private val PKG_EXTENSIONS = setOf(".pkg")
private val ARCHIVE_EXTENSIONS = setOf(".zip", ".vpk")
private val LICENSE_EXTENSIONS = setOf(".bin", ".rif")
private fun customConfigRoute(titleId: String, appName: String): String {
return "settings/custom/${Uri.encode(titleId)}?appName=${Uri.encode(appName)}"
}
@Composable
fun AppNavigation(
appsListViewModel: AppsListViewModel,
installViewModel: InstallViewModel,
settingsViewModel: SettingsViewModel,
userManagementViewModel: UserManagementViewModel,
onAppLaunch: (AppInfo) -> Unit
) {
val navController = rememberNavController()
val context = LocalContext.current
val activity = context as? MainActivity
var showArchiveSourceDialog by remember { mutableStateOf(false) }
var showStandaloneLicenseDialog by remember { mutableStateOf(false) }
val startDestination by produceState<String?>(initialValue = null, key1 = context) {
value = if (AppStorage.isInitialSetupCompleted(context)) ROUTE_APPS_LIST else ROUTE_INITIAL_SETUP
}
LaunchedEffect(appsListViewModel.initialized) {
if (appsListViewModel.initialized) {
settingsViewModel.preloadGlobalSettings()
}
}
LaunchedEffect(installViewModel.completedInstallToken) {
if (installViewModel.completedInstallToken != 0L) {
appsListViewModel.reloadAppsList()
settingsViewModel.load(titleId = null, force = true)
}
}
fun requestInstallPicker(type: InstallType) {
val hostActivity = activity ?: return
when (type) {
InstallType.FIRMWARE -> hostActivity.requestInstallFilePath(FIRMWARE_EXTENSIONS) { path ->
path?.let { installViewModel.installFirmware(it) }
}
InstallType.PKG -> hostActivity.requestInstallFilePath(PKG_EXTENSIONS) { path ->
path?.let { installViewModel.onPkgPicked(it) }
}
InstallType.ARCHIVE -> hostActivity.requestInstallFilePath(ARCHIVE_EXTENSIONS) { path ->
path?.let { installViewModel.installArchive(it) }
}
InstallType.LICENSE -> hostActivity.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
path?.let { installViewModel.installLicense(it) }
}
}
}
if (installViewModel.showPkgLicenseDialog) {
LicenseSourceDialog(
title = stringResource(org.vita3k.emulator.R.string.license_required_title),
message = stringResource(org.vita3k.emulator.R.string.license_required_message),
onSelectLicenseFile = {
activity?.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
if (path != null) {
installViewModel.onPkgLicenseFilePicked(path)
} else {
installViewModel.cancelPkgInstall()
}
}
},
onEnterZrif = { zrif -> installViewModel.confirmPkgInstall(zrif) },
onDismiss = { installViewModel.cancelPkgInstall() }
)
}
if (showArchiveSourceDialog) {
ArchiveInstallSourceDialog(
onSelectFile = {
showArchiveSourceDialog = false
requestInstallPicker(InstallType.ARCHIVE)
},
onSelectFolder = {
showArchiveSourceDialog = false
activity?.requestArchiveFolderPaths(ARCHIVE_EXTENSIONS) { paths ->
if (paths.isNotEmpty()) {
installViewModel.installArchiveFolder(paths)
}
}
},
onDismiss = { showArchiveSourceDialog = false }
)
}
if (showStandaloneLicenseDialog) {
LicenseSourceDialog(
title = stringResource(org.vita3k.emulator.R.string.install_license_source_title),
message = stringResource(org.vita3k.emulator.R.string.install_license_source_message),
onSelectLicenseFile = {
showStandaloneLicenseDialog = false
activity?.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
path?.let { installViewModel.installLicense(it) }
}
},
onEnterZrif = { zrif ->
showStandaloneLicenseDialog = false
installViewModel.installLicenseFromZrif(zrif)
},
onDismiss = { showStandaloneLicenseDialog = false }
)
}
if (installViewModel.showInstallSheet) {
InstallBottomSheet(
onDismiss = { installViewModel.hideSheet() },
onSelectType = {
installViewModel.hideSheet()
when (it) {
InstallType.ARCHIVE -> showArchiveSourceDialog = true
InstallType.LICENSE -> showStandaloneLicenseDialog = true
else -> requestInstallPicker(it)
}
}
)
}
if (installViewModel.installing) {
InstallProgressDialog(
progress = installViewModel.progress,
statusMessage = installViewModel.statusMessage
)
}
installViewModel.installResult?.let { result ->
InstallResultDialog(
result = result,
onConfirm = { selectedOptions -> installViewModel.confirmInstallResult(selectedOptions) }
)
}
appsListViewModel.infoDialogApp?.let { app ->
AppInfoSheet(
app = app,
installSizeBytes = appsListViewModel.infoAppInstallSizeBytes,
onDismiss = { appsListViewModel.dismissAppInfo() }
)
}
if (startDestination == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return
}
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None }
) {
composable(ROUTE_INITIAL_SETUP) {
val completeSetup: () -> Unit = {
AppStorage.setInitialSetupCompleted(context, true)
navController.navigate(ROUTE_APPS_LIST) {
launchSingleTop = true
popUpTo(ROUTE_INITIAL_SETUP) { inclusive = true }
}
}
InitialSetupScreen(
firmwareInstallState = appsListViewModel.firmwareInstallState,
preferredLanguageIndex = settingsViewModel.config.sysLang,
onInstallFirmware = { requestInstallPicker(InstallType.FIRMWARE) },
dismissLabel = stringResource(org.vita3k.emulator.R.string.initial_setup_skip),
onSkip = completeSetup,
onFinish = completeSetup
)
}
composable(ROUTE_INITIAL_SETUP_MANUAL) {
InitialSetupScreen(
firmwareInstallState = appsListViewModel.firmwareInstallState,
preferredLanguageIndex = settingsViewModel.config.sysLang,
onInstallFirmware = { requestInstallPicker(InstallType.FIRMWARE) },
dismissLabel = stringResource(org.vita3k.emulator.R.string.emulation_exit),
onSkip = { navController.popBackStack() },
onFinish = { navController.popBackStack() }
)
}
composable(ROUTE_APPS_LIST) {
AppsListScreen(
apps = appsListViewModel.apps,
initialized = appsListViewModel.initialized,
loading = appsListViewModel.loading,
appVersion = appsListViewModel.appVersion,
searchQuery = appsListViewModel.searchQuery,
sortOption = appsListViewModel.sortOption,
viewMode = appsListViewModel.viewMode,
updateCheckInProgress = appsListViewModel.updateCheckInProgress,
updateCheckResult = appsListViewModel.updateCheckResult,
firmwareInstallState = appsListViewModel.firmwareInstallState,
actionInProgress = appsListViewModel.actionInProgress,
actionResultMessage = appsListViewModel.actionResultMessage,
selectionMode = appsListViewModel.selectionMode,
selectedAppIds = appsListViewModel.selectedAppIds,
resolveAvailableActions = { app -> appsListViewModel.getAvailableActions(app.titleId) },
onAppSelected = { app -> onAppLaunch(app) },
onRunAppAction = { app, action -> appsListViewModel.runAppAction(app, action) },
onPrepareAppActions = { app -> appsListViewModel.prepareAppActions(app) },
onShowAppInfo = { app -> appsListViewModel.showAppInfo(app) },
onToggleAppSelection = { app -> appsListViewModel.toggleAppSelection(app) },
onSetSelectionMode = { enabled -> appsListViewModel.updateSelectionMode(enabled) },
onSelectAllVisible = { appsListViewModel.selectAllVisibleApps() },
onRunBatchDeleteSelected = { appsListViewModel.runBatchDeleteSelected() },
onDismissActionResult = { appsListViewModel.dismissActionResult() },
onSearchChanged = { appsListViewModel.setSearch(it) },
onSortChanged = { appsListViewModel.setSort(it) },
onViewModeToggle = { appsListViewModel.toggleViewMode() },
onCheckForUpdates = { appsListViewModel.checkForUpdates() },
onDismissUpdateCheckResult = { appsListViewModel.dismissUpdateCheckResult() },
onRefresh = { appsListViewModel.refreshAppsList(syncCompatibility = true) },
onInstallClick = { installViewModel.showSheet() },
onOpenSettings = {
navController.navigate(ROUTE_SETTINGS) {
launchSingleTop = true
}
},
onOpenUserManagement = {
navController.navigate(ROUTE_USER_MANAGEMENT) {
launchSingleTop = true
}
},
onOpenWelcomeScreen = {
navController.navigate(ROUTE_INITIAL_SETUP_MANUAL) {
launchSingleTop = true
}
},
onOpenCustomConfig = { app ->
navController.navigate(customConfigRoute(app.titleId, app.title)) {
launchSingleTop = true
}
}
)
}
composable(ROUTE_SETTINGS) {
SettingsRoute(
titleId = null,
appName = null,
viewModel = settingsViewModel,
onStorageChanged = { appsListViewModel.refreshAppsList() },
onBack = { navController.popBackStack() }
)
}
composable(ROUTE_USER_MANAGEMENT) {
UserManagementScreen(
viewModel = userManagementViewModel,
onBack = { navController.popBackStack() },
onUsersChanged = { appsListViewModel.refreshAppsList() }
)
}
composable(
route = ROUTE_CUSTOM_CONFIG,
arguments = listOf(
navArgument(ARG_TITLE_ID) { type = NavType.StringType },
navArgument(ARG_APP_NAME) {
type = NavType.StringType
defaultValue = ""
}
)
) { backStackEntry ->
val titleId = backStackEntry.arguments?.getString(ARG_TITLE_ID).orEmpty()
val appName = backStackEntry.arguments?.getString(ARG_APP_NAME)
?.takeIf { it.isNotBlank() }
?: appsListViewModel.apps.firstOrNull { it.titleId == titleId }?.title
?: titleId
SettingsRoute(
titleId = titleId,
appName = appName,
viewModel = settingsViewModel,
onStorageChanged = { appsListViewModel.refreshAppsList() },
onBack = {
appsListViewModel.refreshAppsList()
navController.popBackStack()
}
)
}
}
}
@@ -0,0 +1,150 @@
package org.vita3k.emulator.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import org.vita3k.emulator.data.AppInfo
import org.vita3k.emulator.ui.theme.SCRIM_ALPHA
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppInfoSheet(
app: AppInfo,
installSizeBytes: Long,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.app_info_title), style = MaterialTheme.typography.titleLarge)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.app_info_cd_close))
}
}
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top
) {
AppIcon(app = app, size = 80)
Column {
Text(app.title, style = MaterialTheme.typography.titleMedium)
Text(
app.titleId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
CompatBadge(app)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp))
InfoRow(label = stringResource(R.string.app_info_field_title), value = app.title)
InfoRow(label = stringResource(R.string.app_info_field_serial), value = app.titleId)
InfoRow(label = stringResource(R.string.app_info_field_app_version), value = app.appVer.ifEmpty { "\u2014" })
InfoRow(label = stringResource(R.string.app_info_field_category), value = app.category.ifEmpty { "\u2014" })
InfoRow(
label = stringResource(R.string.app_info_field_install_size),
value = when {
installSizeBytes == -1L -> stringResource(R.string.app_info_size_calculating)
installSizeBytes == 0L -> stringResource(R.string.app_info_size_unknown)
installSizeBytes < 1024L * 1024L ->
stringResource(R.string.app_info_size_kb, (installSizeBytes / 1024L).toInt())
installSizeBytes < 1024L * 1024L * 1024L ->
stringResource(R.string.app_info_size_mb, (installSizeBytes / (1024.0 * 1024.0)).toFloat())
else ->
stringResource(R.string.app_info_size_gb, (installSizeBytes / (1024.0 * 1024.0 * 1024.0)).toFloat())
}
)
InfoRow(label = stringResource(R.string.app_info_field_play_time), value = formatPlaytime(app.playtime))
InfoRow(label = stringResource(R.string.app_info_field_last_played), value = formatLastPlayed(app.lastPlayed))
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun formatPlaytime(seconds: Long): String {
if (seconds <= 0) return stringResource(R.string.app_info_playtime_never)
val h = seconds / 3600
val m = (seconds % 3600) / 60
val s = seconds % 60
return when {
h > 0 -> stringResource(R.string.app_info_playtime_hm, h.toInt(), m.toInt())
m > 0 -> stringResource(R.string.app_info_playtime_ms, m.toInt(), s.toInt())
else -> stringResource(R.string.app_info_playtime_s, s.toInt())
}
}
@Composable
private fun formatLastPlayed(timestampSecs: Long): String {
if (timestampSecs <= 0) return stringResource(R.string.app_info_last_played_never)
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.systemDefault())
return formatter.format(Instant.ofEpochSecond(timestampSecs))
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun InfoRow(label: String, value: String) {
val clipboardManager = LocalClipboardManager.current
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {},
onLongClick = { clipboardManager.setText(AnnotatedString(value)) }
)
.padding(horizontal = 24.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
package org.vita3k.emulator.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import org.vita3k.emulator.data.SortOption
import org.vita3k.emulator.data.ViewMode
import org.vita3k.emulator.ui.theme.SCRIM_ALPHA
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterSheet(
sortOption: SortOption,
viewMode: ViewMode,
onSortChanged: (SortOption) -> Unit,
onViewModeToggle: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
Text(
stringResource(R.string.filter_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
// Sort section
Text(
stringResource(R.string.filter_section_sort),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
SortOption.entries.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = option == sortOption,
onClick = { onSortChanged(option) }
)
Text(
text = when (option) {
SortOption.TITLE -> stringResource(R.string.apps_list_sort_title)
SortOption.LAST_PLAYED -> stringResource(R.string.apps_list_sort_last_played)
SortOption.COMPATIBILITY -> stringResource(R.string.apps_list_sort_compatibility)
},
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// View mode section
Text(
stringResource(R.string.filter_section_view),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilterChip(
selected = viewMode == ViewMode.LIST,
onClick = { if (viewMode != ViewMode.LIST) onViewModeToggle() },
label = { Text(stringResource(R.string.filter_view_list)) },
leadingIcon = {
Icon(
Icons.AutoMirrored.Filled.List,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
FilterChip(
selected = viewMode == ViewMode.GRID,
onClick = { if (viewMode != ViewMode.GRID) onViewModeToggle() },
label = { Text(stringResource(R.string.filter_view_grid)) },
leadingIcon = {
Icon(
Icons.Default.GridView,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
}
}
}
}
@@ -0,0 +1,528 @@
package org.vita3k.emulator.ui.screens
import android.view.Gravity
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import org.vita3k.emulator.data.FirmwareInstallState
import org.vita3k.emulator.data.FirmwareLinks
import org.vita3k.emulator.ui.components.HtmlText
private const val INITIAL_SETUP_INFO_HTML =
"""<div align="center">To get started, please install all PS Vita firmware files.<br><br>A comprehensive guide is available on the <a href="https://vita3k.org/quickstart.html">Quickstart</a> page.<br>Check the <a href="https://vita3k.org/compatibility.html">commercial</a> and <a href="https://vita3k.org/compatibility-homebrew.html">homebrew</a> compatibility lists to see what currently runs.<br><br>Contributions are welcome on <a href="https://github.com/Vita3K/Vita3K">GitHub</a>, and additional help is available on <a href="https://discord.gg/6aGwQzh">Discord</a>.</div>"""
private val setupPanelColor = Color(0xFF1F1D1C)
private val setupCardColor = Color(0xFF2B2B2B)
private val setupTextColor = Color(0xFFF7F3EC)
@Composable
fun InitialSetupScreen(
firmwareInstallState: FirmwareInstallState,
preferredLanguageIndex: Int,
onInstallFirmware: () -> Unit,
dismissLabel: String,
onSkip: () -> Unit,
onFinish: () -> Unit
) {
val systemBars = WindowInsets.systemBars.asPaddingValues()
var page by rememberSaveable { mutableIntStateOf(0) }
var firmwareLocaleIndex by rememberSaveable {
mutableIntStateOf(FirmwareLinks.coerceLocaleIndex(preferredLanguageIndex))
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f),
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f)
)
)
)
) {
BackgroundOrb(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 24.dp, top = 88.dp),
size = 180.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.09f)
)
BackgroundOrb(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 8.dp, bottom = 72.dp),
size = 220.dp,
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.08f)
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(
start = 20.dp,
end = 20.dp,
top = systemBars.calculateTopPadding() + 8.dp,
bottom = systemBars.calculateBottomPadding() + 20.dp
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (page > 0) {
TextButton(onClick = { page-- }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.initial_setup_back))
}
} else {
Spacer(modifier = Modifier.width(96.dp))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onSkip) {
Text(dismissLabel)
}
}
Spacer(modifier = Modifier.height(12.dp))
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
shape = RoundedCornerShape(32.dp),
color = setupPanelColor,
contentColor = setupTextColor,
tonalElevation = 6.dp,
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
)
) {
Crossfade(
targetState = page,
animationSpec = tween(durationMillis = 220),
label = "initial-setup-page"
) { currentPage ->
when (currentPage) {
0 -> WelcomePage()
else -> FirmwareSetupPage(
firmwareInstallState = firmwareInstallState,
firmwareLocaleIndex = firmwareLocaleIndex,
onFirmwareLocaleSelected = { firmwareLocaleIndex = it },
onInstallFirmware = onInstallFirmware
)
}
}
}
Spacer(modifier = Modifier.height(18.dp))
SetupPageIndicator(
currentPage = page,
totalPages = 2,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(1.dp))
if (page == 0) {
Button(onClick = { page = 1 }) {
Text(stringResource(R.string.initial_setup_next))
Spacer(modifier = Modifier.width(8.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
}
} else {
Button(onClick = onFinish) {
Text(stringResource(R.string.initial_setup_open_library))
}
}
}
}
}
}
@Composable
private fun BackgroundOrb(
modifier: Modifier = Modifier,
size: androidx.compose.ui.unit.Dp,
color: Color
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color)
)
}
@Composable
private fun WelcomePage() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp, vertical = 32.dp)
.heightIn(min = maxHeight - 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher),
contentDescription = stringResource(R.string.apps_list_app_title),
modifier = Modifier
.size(112.dp)
.alpha(0.96f)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.initial_setup_welcome_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
color = setupTextColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(R.string.initial_setup_welcome_body),
style = MaterialTheme.typography.bodyLarge,
color = setupTextColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(18.dp))
HtmlText(
html = INITIAL_SETUP_INFO_HTML,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyLarge,
textColor = setupTextColor,
gravity = Gravity.CENTER
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = stringResource(R.string.initial_setup_piracy_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FirmwareSetupPage(
firmwareInstallState: FirmwareInstallState,
firmwareLocaleIndex: Int,
onFirmwareLocaleSelected: (Int) -> Unit,
onInstallFirmware: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = stringResource(R.string.initial_setup_firmware_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = setupTextColor
)
Text(
text = stringResource(R.string.initial_setup_firmware_body),
style = MaterialTheme.typography.bodyLarge,
color = setupTextColor
)
FirmwareCard(
title = stringResource(R.string.initial_setup_preinstall_title),
installed = firmwareInstallState.components.preinstalled,
missingStatusText = stringResource(R.string.initial_setup_status_optional)
) {
if (!firmwareInstallState.components.preinstalled) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
FilledTonalButton(onClick = { uriHandler.openUri(FirmwareLinks.PREINSTALL_URL) }) {
Text(stringResource(R.string.initial_setup_download))
}
Button(onClick = onInstallFirmware) {
Text(stringResource(R.string.initial_setup_install_pup))
}
}
}
}
FirmwareCard(
title = stringResource(R.string.initial_setup_main_title),
installed = firmwareInstallState.components.main
) {
if (!firmwareInstallState.components.main) {
FirmwareLanguagePicker(
selectedIndex = firmwareLocaleIndex,
onSelected = onFirmwareLocaleSelected
)
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
FilledTonalButton(onClick = {
uriHandler.openUri(FirmwareLinks.firmwareDownloadUrl(firmwareLocaleIndex))
}) {
Text(stringResource(R.string.initial_setup_download))
}
Button(onClick = onInstallFirmware) {
Text(stringResource(R.string.initial_setup_install_pup))
}
}
}
}
FirmwareCard(
title = stringResource(R.string.initial_setup_font_title),
installed = firmwareInstallState.components.fontPackage
) {
if (!firmwareInstallState.components.fontPackage) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
FilledTonalButton(onClick = { uriHandler.openUri(FirmwareLinks.FONT_PACKAGE_URL) }) {
Text(stringResource(R.string.initial_setup_download))
}
Button(onClick = onInstallFirmware) {
Text(stringResource(R.string.initial_setup_install_pup))
}
}
}
}
}
}
@Composable
private fun FirmwareLanguagePicker(
selectedIndex: Int,
onSelected: (Int) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.initial_setup_select_firmware_language),
style = MaterialTheme.typography.labelLarge,
color = setupTextColor
)
Box {
OutlinedButton(onClick = { expanded = true }) {
Text(stringResource(FirmwareLinks.locales[FirmwareLinks.coerceLocaleIndex(selectedIndex)].nameResId))
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
FirmwareLinks.locales.forEachIndexed { index, locale ->
DropdownMenuItem(
text = { Text(stringResource(locale.nameResId)) },
onClick = {
onSelected(index)
expanded = false
}
)
}
}
}
}
}
@Composable
private fun FirmwareCard(
title: String,
installed: Boolean,
missingStatusText: String? = null,
content: @Composable ColumnScope.() -> Unit
) {
val resolvedMissingStatusText = missingStatusText ?: stringResource(R.string.initial_setup_status_missing)
Surface(
shape = RoundedCornerShape(28.dp),
color = setupCardColor,
contentColor = setupTextColor,
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = setupTextColor,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(12.dp))
StatusBadge(
installed = installed,
missingStatusText = resolvedMissingStatusText
)
}
content()
}
}
}
@Composable
private fun StatusBadge(installed: Boolean, missingStatusText: String) {
val color = if (installed) Color(0xFF1B8A5A) else MaterialTheme.colorScheme.tertiary
Surface(
shape = CircleShape,
color = color.copy(alpha = 0.13f)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(color)
)
Text(
text = if (installed) {
stringResource(R.string.initial_setup_status_installed)
} else {
missingStatusText
},
style = MaterialTheme.typography.labelLarge,
color = color,
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
private fun SetupPageIndicator(
currentPage: Int,
totalPages: Int,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(totalPages) { page ->
val width by animateDpAsState(
targetValue = if (page == currentPage) 22.dp else 8.dp,
animationSpec = tween(durationMillis = 220),
label = "initial-setup-indicator"
)
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.height(8.dp)
.width(width)
.clip(CircleShape)
.background(
if (page == currentPage) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
)
)
}
}
}
@@ -0,0 +1,273 @@
package org.vita3k.emulator.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import org.vita3k.emulator.ui.viewmodel.DeleteSourceOption
import org.vita3k.emulator.ui.viewmodel.InstallResult
import org.vita3k.emulator.ui.viewmodel.InstallResultStatus
import org.vita3k.emulator.ui.viewmodel.InstallType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InstallBottomSheet(
onDismiss: () -> Unit,
onSelectType: (InstallType) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val itemColors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface,
headlineColor = MaterialTheme.colorScheme.onSurface,
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
leadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Text(
stringResource(R.string.install_sheet_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
ListItem(
headlineContent = { Text(stringResource(R.string.install_firmware_title)) },
supportingContent = { Text(stringResource(R.string.install_firmware_desc)) },
leadingContent = { Icon(Icons.Default.SystemUpdate, contentDescription = null) },
colors = itemColors,
modifier = Modifier.clickable { onSelectType(InstallType.FIRMWARE) }
)
ListItem(
headlineContent = { Text(stringResource(R.string.install_pkg_title)) },
supportingContent = { Text(stringResource(R.string.install_pkg_desc)) },
leadingContent = { Icon(Icons.Default.Inventory2, contentDescription = null) },
colors = itemColors,
modifier = Modifier.clickable { onSelectType(InstallType.PKG) }
)
ListItem(
headlineContent = { Text(stringResource(R.string.install_archive_title)) },
supportingContent = { Text(stringResource(R.string.install_archive_desc)) },
leadingContent = { Icon(Icons.Default.FolderZip, contentDescription = null) },
colors = itemColors,
modifier = Modifier.clickable { onSelectType(InstallType.ARCHIVE) }
)
ListItem(
headlineContent = { Text(stringResource(R.string.install_license_title)) },
supportingContent = { Text(stringResource(R.string.install_license_desc)) },
leadingContent = { Icon(Icons.Default.Key, contentDescription = null) },
colors = itemColors,
modifier = Modifier.clickable { onSelectType(InstallType.LICENSE) }
)
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
fun InstallProgressDialog(
progress: Int,
statusMessage: String
) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.install_progress_title)) },
text = {
Column {
Text(statusMessage)
Spacer(modifier = Modifier.height(16.dp))
if (progress > 0) {
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
drawStopIndicator = {}
)
Text(
"$progress%",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
},
confirmButton = {}
)
}
@Composable
fun InstallResultDialog(
result: InstallResult,
onConfirm: (List<DeleteSourceOption>) -> Unit
) {
var selectedDeleteOptions by remember(result) {
mutableStateOf(emptySet<Int>())
}
AlertDialog(
onDismissRequest = { onConfirm(emptyList()) },
title = {
Text(
when (result.status) {
InstallResultStatus.SUCCESS -> stringResource(R.string.install_result_success_title)
InstallResultStatus.PARTIAL -> stringResource(R.string.install_result_partial_title)
InstallResultStatus.ERROR -> stringResource(R.string.install_result_failed_title)
}
)
},
text = {
Column {
Text(result.message)
if (result.deleteOptions.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
result.deleteOptions.forEachIndexed { index, option ->
Row(
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = index in selectedDeleteOptions,
onCheckedChange = { checked ->
selectedDeleteOptions = if (checked) {
selectedDeleteOptions + index
} else {
selectedDeleteOptions - index
}
}
)
Text(
text = option.label,
modifier = Modifier.padding(top = 12.dp)
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(
selectedDeleteOptions
.sorted()
.map(result.deleteOptions::get)
)
}
) {
Text(stringResource(R.string.action_ok))
}
}
)
}
@Composable
fun LicenseSourceDialog(
title: String,
message: String,
onSelectLicenseFile: () -> Unit,
onEnterZrif: (String) -> Unit,
onDismiss: () -> Unit
) {
var showManualEntry by remember { mutableStateOf(false) }
var zrif by remember { mutableStateOf("") }
if (showManualEntry) {
AlertDialog(
onDismissRequest = { showManualEntry = false },
title = { Text(stringResource(R.string.zrif_dialog_title)) },
text = {
OutlinedTextField(
value = zrif,
onValueChange = { zrif = it.trim() },
label = { Text(stringResource(R.string.zrif_dialog_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = { onEnterZrif(zrif) },
enabled = zrif.isNotEmpty()
) { Text(stringResource(R.string.action_install)) }
},
dismissButton = {
TextButton(onClick = { showManualEntry = false }) { Text(stringResource(R.string.action_back)) }
}
)
} else {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
Text(
message,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(
onClick = onSelectLicenseFile,
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.license_select_file)) }
Spacer(modifier = Modifier.height(8.dp))
FilledTonalButton(
onClick = { showManualEntry = true },
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.license_enter_zrif)) }
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
}
@Composable
fun ArchiveInstallSourceDialog(
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.install_archive_source_title)) },
text = {
Column {
Text(
stringResource(R.string.install_archive_source_message),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(
onClick = onSelectFile,
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.install_archive_source_file)) }
Spacer(modifier = Modifier.height(8.dp))
FilledTonalButton(
onClick = onSelectFolder,
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.install_archive_source_folder)) }
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@@ -0,0 +1,418 @@
package org.vita3k.emulator.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
import org.vita3k.emulator.data.NativeUser
import org.vita3k.emulator.ui.theme.ApplyDialogDim
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserManagementScreen(
viewModel: UserManagementViewModel,
onBack: () -> Unit,
onUsersChanged: () -> Unit
) {
var showCreateDialog by rememberSaveable { mutableStateOf(false) }
var pendingDeletion by remember { mutableStateOf<NativeUser?>(null) }
val activeUser = viewModel.users.firstOrNull { it.active }
LaunchedEffect(Unit) {
viewModel.load()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
title = { Text(stringResource(R.string.user_management_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
},
actions = {
IconButton(onClick = viewModel::load, enabled = !viewModel.loading && !viewModel.busy) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.user_management_refresh)
)
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.user_management_create_user)) },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
onClick = { showCreateDialog = true },
expanded = true
)
}
) { padding ->
when {
viewModel.loading && viewModel.users.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
ActiveUserHero(
activeUser = activeUser,
onCreateUser = { showCreateDialog = true }
)
}
if (viewModel.users.isEmpty()) {
item {
EmptyUsersCard(onCreateUser = { showCreateDialog = true })
}
} else {
items(viewModel.users, key = { it.id }) { user ->
UserCard(
user = user,
busy = viewModel.busy,
onActivate = { viewModel.activateUser(user, onUsersChanged) },
onDelete = { pendingDeletion = user }
)
}
}
}
}
}
}
if (showCreateDialog) {
CreateUserDialog(
busy = viewModel.busy,
onDismiss = { showCreateDialog = false },
onCreate = { name ->
showCreateDialog = false
viewModel.createUser(name, onUsersChanged)
}
)
}
pendingDeletion?.let { user ->
AlertDialog(
onDismissRequest = { pendingDeletion = null },
title = { Text(stringResource(R.string.user_management_delete_title)) },
text = {
ApplyDialogDim()
Text(stringResource(R.string.user_management_delete_message, user.name))
},
confirmButton = {
TextButton(
onClick = {
pendingDeletion = null
viewModel.deleteUser(user, onUsersChanged)
}
) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { pendingDeletion = null }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
viewModel.operationResult?.let { result ->
AlertDialog(
onDismissRequest = { viewModel.dismissOperationResult() },
title = {
Text(
if (result.isError) {
stringResource(R.string.settings_opt_error)
} else {
stringResource(R.string.settings_success_title)
}
)
},
text = {
ApplyDialogDim()
Text(result.message)
},
confirmButton = {
TextButton(onClick = { viewModel.dismissOperationResult() }) {
Text(stringResource(R.string.action_ok))
}
}
)
}
}
@Composable
private fun ActiveUserHero(
activeUser: NativeUser?,
onCreateUser: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.1f),
shape = RoundedCornerShape(20.dp)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(12.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.user_management_active_user),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f)
)
Text(
text = activeUser?.name ?: stringResource(R.string.user_management_active_none),
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
activeUser?.let {
Text(
text = it.id,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
)
}
}
}
FilledTonalButton(onClick = onCreateUser) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.user_management_create_user))
}
}
}
}
@Composable
private fun EmptyUsersCard(
onCreateUser: () -> Unit
) {
ElevatedCard(shape = RoundedCornerShape(24.dp)) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.user_management_empty_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.user_management_empty_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FilledTonalButton(onClick = onCreateUser) {
Text(stringResource(R.string.user_management_create_user))
}
}
}
}
@Composable
private fun UserCard(
user: NativeUser,
busy: Boolean,
onActivate: () -> Unit,
onDelete: () -> Unit
) {
ElevatedCard(shape = RoundedCornerShape(24.dp)) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = user.id,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (user.active) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringResource(R.string.user_management_active_badge)) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
FilledTonalButton(
onClick = onActivate,
enabled = !busy && !user.active,
modifier = Modifier.weight(1f)
) {
Text(
text = if (user.active) {
stringResource(R.string.user_management_active_badge)
} else {
stringResource(R.string.user_management_switch_user)
}
)
}
OutlinedButton(
onClick = onDelete,
enabled = !busy,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.user_management_delete_user))
}
}
}
}
}
@Composable
private fun CreateUserDialog(
busy: Boolean,
onDismiss: () -> Unit,
onCreate: (String) -> Unit
) {
var userName by rememberSaveable { mutableStateOf("Vita3K") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.user_management_create_user)) },
text = {
ApplyDialogDim()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.user_management_create_prompt))
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
singleLine = true,
label = { Text(stringResource(R.string.user_management_name_label)) },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
enabled = !busy && userName.trim().isNotEmpty(),
onClick = { onCreate(userName.trim()) }
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
@@ -0,0 +1,233 @@
package org.vita3k.emulator.ui.screens.emulation
import android.view.View
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.Emulator
import org.vita3k.emulator.NativeLib
import org.vita3k.emulator.R
import org.vita3k.emulator.data.NativeImeState
import org.vita3k.emulator.ui.theme.Vita3KTheme
import org.vita3k.emulator.ui.viewmodel.EmulationSessionViewModel
private val ImeOverlayBackground = Color.Black.copy(alpha = 0.30f)
private val ImeOverlayText = Color.Black.copy(alpha = 0.96f)
private val ImeOverlayPlaceholder = Color.Black.copy(alpha = 0.70f)
private val ImeOverlayAccent = Color.Black.copy(alpha = 0.96f)
object NativeImeOverlayHost {
@JvmStatic
fun attach(
composeView: ComposeView,
sessionViewModel: EmulationSessionViewModel
) {
composeView.setContent {
Vita3KTheme {
NativeImeOverlay(
sessionViewModel = sessionViewModel,
hostView = composeView
)
}
}
}
}
@Composable
private fun NativeImeOverlay(
sessionViewModel: EmulationSessionViewModel,
hostView: ComposeView
) {
val uiState = sessionViewModel.uiState
val imeState = sessionViewModel.imeState
val context = LocalContext.current
val state = imeState
val visible = state?.sceImeActive == true
&& state.dialogActive.not()
&& !uiState.showMenu
&& !uiState.isEditingControls
SideEffect {
hostView.visibility = if (visible) View.VISIBLE else View.GONE
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(180)) + slideInVertically(initialOffsetY = { -it / 2 }),
exit = fadeOut(tween(140)) + slideOutVertically(targetOffsetY = { -it / 3 })
) {
state?.let { current ->
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(WindowInsets.displayCutout.asPaddingValues())
.padding(WindowInsets.statusBars.asPaddingValues())
.padding(horizontal = 16.dp, vertical = 10.dp),
contentAlignment = Alignment.TopCenter
) {
val gameViewportWidth = minOf(maxWidth, maxHeight * (960f / 544f))
val preview = buildImePreview(current)
Surface(
modifier = Modifier.width(gameViewportWidth),
shape = RoundedCornerShape(22.dp),
color = ImeOverlayBackground,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
val buttonInset = if (current.enterLabel.isNotBlank()) 96.dp else 0.dp
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = buttonInset, end = buttonInset),
contentAlignment = Alignment.Center
) {
if (preview.text.isBlank()) {
Text(
text = stringResource(R.string.emulation_ime_placeholder),
style = MaterialTheme.typography.titleMedium,
color = ImeOverlayPlaceholder,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
text = preview,
style = MaterialTheme.typography.titleMedium,
color = ImeOverlayText,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (current.enterLabel.isNotBlank()) {
Box(
modifier = Modifier.align(Alignment.CenterEnd)
) {
TextButton(
onClick = {
val activity = context as? Emulator
if (activity != null) {
activity.completeImeFromKeyboard(null)
} else {
runCatching { NativeLib.submitIme() }
}
},
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(ImeOverlayBackground)
) {
Text(
text = current.enterLabel,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = ImeOverlayText
)
}
}
}
}
}
}
}
}
}
private fun buildImePreview(state: NativeImeState): AnnotatedString {
val text = state.text
val caretIndex = state.caretIndex.coerceIn(0, text.length)
val preeditStart = state.preeditStart.coerceIn(0, text.length)
val preeditEnd = (preeditStart + state.preeditLength).coerceIn(preeditStart, text.length)
val caretStyle = SpanStyle(
color = ImeOverlayAccent,
fontWeight = FontWeight.Bold
)
val preeditStyle = SpanStyle(
color = ImeOverlayAccent,
textDecoration = TextDecoration.Underline
)
return buildAnnotatedString {
fun appendCaret(position: Int) {
if (caretIndex == position) {
withStyle(caretStyle) {
append('|')
}
}
}
if (text.isEmpty()) {
appendCaret(0)
return@buildAnnotatedString
}
appendCaret(0)
var cursor = 0
while (cursor < text.length) {
val segmentEnd = when {
cursor < preeditStart -> preeditStart
cursor < preeditEnd -> preeditEnd
else -> text.length
}
val segment = text.substring(cursor, segmentEnd)
if (cursor >= preeditStart && cursor < preeditEnd) {
withStyle(preeditStyle) {
append(segment)
}
} else {
append(segment)
}
cursor = segmentEnd
appendCaret(cursor)
}
}
}
@@ -0,0 +1,594 @@
package org.vita3k.emulator.ui.screens.settings
import android.content.Context
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import org.vita3k.emulator.ConnectedGamepad
import org.vita3k.emulator.ControllerFamily
import org.vita3k.emulator.R
import org.vita3k.emulator.data.EmulatorConfig
import org.vita3k.emulator.ui.theme.ApplyDialogDim
import kotlin.math.abs
import kotlin.math.roundToInt
private const val SDL_GAMEPAD_BUTTON_SOUTH = 0
private const val SDL_GAMEPAD_BUTTON_EAST = 1
private const val SDL_GAMEPAD_BUTTON_WEST = 2
private const val SDL_GAMEPAD_BUTTON_NORTH = 3
private const val SDL_GAMEPAD_BUTTON_BACK = 4
private const val SDL_GAMEPAD_BUTTON_GUIDE = 5
private const val SDL_GAMEPAD_BUTTON_START = 6
private const val SDL_GAMEPAD_BUTTON_LEFT_STICK = 7
private const val SDL_GAMEPAD_BUTTON_RIGHT_STICK = 8
private const val SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
private const val SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
private const val SDL_GAMEPAD_BUTTON_DPAD_UP = 11
private const val SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
private const val SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
private const val SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
private const val SDL_GAMEPAD_AXIS_LEFTX = 0
private const val SDL_GAMEPAD_AXIS_LEFTY = 1
private const val SDL_GAMEPAD_AXIS_RIGHTX = 2
private const val SDL_GAMEPAD_AXIS_RIGHTY = 3
private const val SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
private const val SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
private data class ButtonBindingRow(
@StringRes val labelRes: Int,
val bindIndex: Int
)
private data class AxisBindingRow(
@StringRes val labelRes: Int,
val bindIndex: Int
)
private data class ManualBindingOption(
val label: String,
val value: Int
)
private enum class CaptureMode {
Button,
Axis
}
private val buttonBindingRows = listOf(
ButtonBindingRow(R.string.settings_controls_bind_dpad_up, SDL_GAMEPAD_BUTTON_DPAD_UP),
ButtonBindingRow(R.string.settings_controls_bind_dpad_down, SDL_GAMEPAD_BUTTON_DPAD_DOWN),
ButtonBindingRow(R.string.settings_controls_bind_dpad_left, SDL_GAMEPAD_BUTTON_DPAD_LEFT),
ButtonBindingRow(R.string.settings_controls_bind_dpad_right, SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
ButtonBindingRow(R.string.settings_controls_bind_triangle, SDL_GAMEPAD_BUTTON_NORTH),
ButtonBindingRow(R.string.settings_controls_bind_circle, SDL_GAMEPAD_BUTTON_EAST),
ButtonBindingRow(R.string.settings_controls_bind_cross, SDL_GAMEPAD_BUTTON_SOUTH),
ButtonBindingRow(R.string.settings_controls_bind_square, SDL_GAMEPAD_BUTTON_WEST),
ButtonBindingRow(R.string.settings_controls_bind_l1, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
ButtonBindingRow(R.string.settings_controls_bind_r1, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
ButtonBindingRow(R.string.settings_controls_bind_l3, SDL_GAMEPAD_BUTTON_LEFT_STICK),
ButtonBindingRow(R.string.settings_controls_bind_r3, SDL_GAMEPAD_BUTTON_RIGHT_STICK),
ButtonBindingRow(R.string.settings_controls_bind_select, SDL_GAMEPAD_BUTTON_BACK),
ButtonBindingRow(R.string.settings_controls_bind_start, SDL_GAMEPAD_BUTTON_START),
ButtonBindingRow(R.string.settings_controls_bind_ps_button, SDL_GAMEPAD_BUTTON_GUIDE)
)
private val axisBindingRows = listOf(
AxisBindingRow(R.string.settings_controls_bind_left_stick_up, SDL_GAMEPAD_AXIS_LEFTY),
AxisBindingRow(R.string.settings_controls_bind_left_stick_down, SDL_GAMEPAD_AXIS_LEFTY),
AxisBindingRow(R.string.settings_controls_bind_left_stick_left, SDL_GAMEPAD_AXIS_LEFTX),
AxisBindingRow(R.string.settings_controls_bind_left_stick_right, SDL_GAMEPAD_AXIS_LEFTX),
AxisBindingRow(R.string.settings_controls_bind_right_stick_up, SDL_GAMEPAD_AXIS_RIGHTY),
AxisBindingRow(R.string.settings_controls_bind_right_stick_down, SDL_GAMEPAD_AXIS_RIGHTY),
AxisBindingRow(R.string.settings_controls_bind_right_stick_left, SDL_GAMEPAD_AXIS_RIGHTX),
AxisBindingRow(R.string.settings_controls_bind_right_stick_right, SDL_GAMEPAD_AXIS_RIGHTX),
AxisBindingRow(R.string.settings_controls_bind_l2, SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
AxisBindingRow(R.string.settings_controls_bind_r2, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)
)
@Composable
internal fun ControllerMappingSections(
cfg: EmulatorConfig,
connectedGamepads: List<ConnectedGamepad>,
onUpdate: (EmulatorConfig.() -> Unit) -> Unit,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val controllerFamily = connectedGamepads.firstOrNull()?.family ?: ControllerFamily.Standard
var pendingButtonBinding by remember { mutableStateOf<ButtonBindingRow?>(null) }
var pendingAxisBinding by remember { mutableStateOf<AxisBindingRow?>(null) }
SettingsSectionCard(
title = stringResource(R.string.settings_controls_connected_title),
summary = if (connectedGamepads.isEmpty()) {
stringResource(R.string.settings_controls_connected_none)
} else {
connectedGamepads.joinToString(separator = "\n") { it.name }
},
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_connected_title),
body = stringResource(R.string.settings_controls_connected_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
) {
if (connectedGamepads.isEmpty()) {
SettingsNote(text = stringResource(R.string.settings_controls_manual_available))
}
}
SettingsSectionCard(
title = stringResource(R.string.settings_controls_mapping_buttons_title),
summary = stringResource(R.string.settings_controls_mapping_buttons_desc),
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_mapping_buttons_title),
body = stringResource(R.string.settings_controls_mapping_buttons_help),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
) {
buttonBindingRows.forEach { row ->
SettingsActionRow(
title = stringResource(row.labelRes),
value = buttonLabel(controllerFamily, currentButtonBinding(cfg, row.bindIndex)),
onClick = { pendingButtonBinding = row },
help = null,
onShowHelp = onShowHelp
)
}
}
SettingsSectionCard(
title = stringResource(R.string.settings_controls_mapping_axes_title),
summary = stringResource(R.string.settings_controls_mapping_axes_desc),
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_mapping_axes_title),
body = stringResource(R.string.settings_controls_mapping_axes_help),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
) {
SettingsNote(text = stringResource(R.string.settings_controls_mapping_axes_note))
axisBindingRows.forEach { row ->
SettingsActionRow(
title = stringResource(row.labelRes),
value = axisLabel(controllerFamily, currentAxisBinding(cfg, row.bindIndex)),
onClick = { pendingAxisBinding = row },
help = null,
onShowHelp = onShowHelp
)
}
}
SettingsSectionCard(
title = stringResource(R.string.settings_controls_behavior_title),
summary = null,
help = null,
onShowHelp = onShowHelp
) {
SettingsSliderRow(
title = stringResource(R.string.settings_controls_analog_multiplier),
valueLabel = stringResource(
R.string.settings_controls_multiplier_value,
cfg.controllerAnalogMultiplier.coerceIn(0.1f, 2.0f)
),
value = (cfg.controllerAnalogMultiplier * 100f).coerceIn(10f, 200f),
onValueChange = {
onUpdate {
controllerAnalogMultiplier = (it.roundToInt() / 100f).coerceIn(0.1f, 2.0f)
}
},
valueRange = 10f..200f,
steps = 189,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_analog_multiplier),
body = stringResource(R.string.settings_controls_analog_multiplier_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_disable_motion),
checked = cfg.disableMotion,
onCheckedChange = { checked ->
onUpdate { disableMotion = checked }
},
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_disable_motion),
body = stringResource(R.string.settings_controls_disable_motion_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
}
pendingButtonBinding?.let { row ->
BindingCaptureDialog(
title = stringResource(row.labelRes),
prompt = stringResource(R.string.settings_controls_capture_button_prompt),
options = buttonManualOptions(controllerFamily),
selectedValue = currentButtonBinding(cfg, row.bindIndex),
captureMode = CaptureMode.Button,
connectedGamepads = connectedGamepads,
onDismiss = { pendingButtonBinding = null },
onSelected = { mappedButton ->
onUpdate {
controllerBinds = controllerBinds.copyOf().also { binds ->
binds[row.bindIndex] = mappedButton
}
}
pendingButtonBinding = null
}
)
}
pendingAxisBinding?.let { row ->
BindingCaptureDialog(
title = stringResource(row.labelRes),
prompt = stringResource(R.string.settings_controls_capture_axis_prompt),
options = axisManualOptions(controllerFamily),
selectedValue = currentAxisBinding(cfg, row.bindIndex),
captureMode = CaptureMode.Axis,
connectedGamepads = connectedGamepads,
onDismiss = { pendingAxisBinding = null },
onSelected = { mappedAxis ->
onUpdate {
controllerAxisBinds = controllerAxisBinds.copyOf().also { binds ->
binds[row.bindIndex] = mappedAxis
}
}
pendingAxisBinding = null
}
)
}
}
@Composable
private fun BindingCaptureDialog(
title: String,
prompt: String,
options: List<ManualBindingOption>,
selectedValue: Int,
captureMode: CaptureMode,
connectedGamepads: List<ConnectedGamepad>,
onDismiss: () -> Unit,
onSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
ApplyDialogDim()
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = prompt,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (connectedGamepads.isNotEmpty()) {
Text(
text = connectedGamepads.joinToString(separator = "\n") { it.name },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CaptureInputSurface(
captureMode = captureMode,
enabled = connectedGamepads.isNotEmpty(),
onCaptured = onSelected
)
SettingsScrollableChoiceSelector(
title = stringResource(R.string.settings_controls_manual_selection),
options = options.map { it.label },
selectedIndex = options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0),
onSelected = { index -> onSelected(options[index].value) },
summary = options.firstOrNull { it.value == selectedValue }?.label,
help = null,
onShowHelp = {}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
}
}
}
}
@Composable
private fun CaptureInputSurface(
captureMode: CaptureMode,
enabled: Boolean,
onCaptured: (Int) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = if (enabled) {
stringResource(R.string.settings_controls_capture_listening)
} else {
stringResource(R.string.settings_controls_capture_waiting_for_controller)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
AndroidView(
factory = { context ->
GamepadCaptureView(context).apply {
this.captureMode = captureMode
this.onCaptured = onCaptured
}
},
update = { view ->
view.captureMode = captureMode
view.onCaptured = onCaptured
if (enabled) {
view.post {
view.requestFocusFromTouch()
view.requestFocus()
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
)
}
}
}
private class GamepadCaptureView(context: Context) : View(context) {
var captureMode: CaptureMode = CaptureMode.Button
var onCaptured: (Int) -> Unit = {}
init {
isFocusable = true
isFocusableInTouchMode = true
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
post {
requestFocusFromTouch()
requestFocus()
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (captureMode != CaptureMode.Button) {
return super.dispatchKeyEvent(event)
}
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !isGamepadDevice(event.device)) {
return super.dispatchKeyEvent(event)
}
val mapped = keyCodeToSdlButton(event.keyCode) ?: return super.dispatchKeyEvent(event)
onCaptured(mapped)
return true
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (captureMode != CaptureMode.Axis || !event.isFromSource(InputDevice.SOURCE_JOYSTICK)) {
return super.onGenericMotionEvent(event)
}
val mapped = dominantAxisMapping(event) ?: return super.onGenericMotionEvent(event)
onCaptured(mapped)
return true
}
}
private fun isGamepadDevice(device: InputDevice?): Boolean {
device ?: return false
val sources = device.sources
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
}
private fun dominantAxisMapping(event: MotionEvent): Int? {
val candidates = listOf(
MotionEvent.AXIS_X to SDL_GAMEPAD_AXIS_LEFTX,
MotionEvent.AXIS_Y to SDL_GAMEPAD_AXIS_LEFTY,
MotionEvent.AXIS_Z to SDL_GAMEPAD_AXIS_RIGHTX,
MotionEvent.AXIS_RZ to SDL_GAMEPAD_AXIS_RIGHTY,
MotionEvent.AXIS_RX to SDL_GAMEPAD_AXIS_RIGHTX,
MotionEvent.AXIS_RY to SDL_GAMEPAD_AXIS_RIGHTY,
MotionEvent.AXIS_LTRIGGER to SDL_GAMEPAD_AXIS_LEFT_TRIGGER,
MotionEvent.AXIS_BRAKE to SDL_GAMEPAD_AXIS_LEFT_TRIGGER,
MotionEvent.AXIS_RTRIGGER to SDL_GAMEPAD_AXIS_RIGHT_TRIGGER,
MotionEvent.AXIS_GAS to SDL_GAMEPAD_AXIS_RIGHT_TRIGGER
)
var bestValue = 0f
var bestAxis: Int? = null
for ((androidAxis, sdlAxis) in candidates) {
val value = abs(event.getAxisValue(androidAxis))
if (value > 0.6f && value > bestValue) {
bestValue = value
bestAxis = sdlAxis
}
}
return bestAxis
}
private fun keyCodeToSdlButton(keyCode: Int): Int? {
return when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> SDL_GAMEPAD_BUTTON_SOUTH
KeyEvent.KEYCODE_BUTTON_B -> SDL_GAMEPAD_BUTTON_EAST
KeyEvent.KEYCODE_BUTTON_X -> SDL_GAMEPAD_BUTTON_WEST
KeyEvent.KEYCODE_BUTTON_Y -> SDL_GAMEPAD_BUTTON_NORTH
KeyEvent.KEYCODE_BUTTON_SELECT -> SDL_GAMEPAD_BUTTON_BACK
KeyEvent.KEYCODE_BUTTON_MODE -> SDL_GAMEPAD_BUTTON_GUIDE
KeyEvent.KEYCODE_BUTTON_START -> SDL_GAMEPAD_BUTTON_START
KeyEvent.KEYCODE_BUTTON_THUMBL -> SDL_GAMEPAD_BUTTON_LEFT_STICK
KeyEvent.KEYCODE_BUTTON_THUMBR -> SDL_GAMEPAD_BUTTON_RIGHT_STICK
KeyEvent.KEYCODE_BUTTON_L1 -> SDL_GAMEPAD_BUTTON_LEFT_SHOULDER
KeyEvent.KEYCODE_BUTTON_R1 -> SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER
KeyEvent.KEYCODE_DPAD_UP -> SDL_GAMEPAD_BUTTON_DPAD_UP
KeyEvent.KEYCODE_DPAD_DOWN -> SDL_GAMEPAD_BUTTON_DPAD_DOWN
KeyEvent.KEYCODE_DPAD_LEFT -> SDL_GAMEPAD_BUTTON_DPAD_LEFT
KeyEvent.KEYCODE_DPAD_RIGHT -> SDL_GAMEPAD_BUTTON_DPAD_RIGHT
else -> null
}
}
private fun currentButtonBinding(cfg: EmulatorConfig, index: Int): Int {
return if (index in cfg.controllerBinds.indices) {
cfg.controllerBinds[index]
} else {
index
}
}
private fun currentAxisBinding(cfg: EmulatorConfig, index: Int): Int {
return if (index in cfg.controllerAxisBinds.indices) {
cfg.controllerAxisBinds[index]
} else {
index
}
}
private fun buttonManualOptions(family: ControllerFamily): List<ManualBindingOption> = listOf(
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_UP), SDL_GAMEPAD_BUTTON_DPAD_UP),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_DOWN), SDL_GAMEPAD_BUTTON_DPAD_DOWN),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_LEFT), SDL_GAMEPAD_BUTTON_DPAD_LEFT),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_RIGHT), SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER), SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_LEFT_STICK), SDL_GAMEPAD_BUTTON_LEFT_STICK),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_RIGHT_STICK), SDL_GAMEPAD_BUTTON_RIGHT_STICK),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_BACK), SDL_GAMEPAD_BUTTON_BACK),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_GUIDE), SDL_GAMEPAD_BUTTON_GUIDE),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_START), SDL_GAMEPAD_BUTTON_START),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_NORTH), SDL_GAMEPAD_BUTTON_NORTH),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_EAST), SDL_GAMEPAD_BUTTON_EAST),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_SOUTH), SDL_GAMEPAD_BUTTON_SOUTH),
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_WEST), SDL_GAMEPAD_BUTTON_WEST)
)
private fun axisManualOptions(family: ControllerFamily): List<ManualBindingOption> = listOf(
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFTX), SDL_GAMEPAD_AXIS_LEFTX),
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFTY), SDL_GAMEPAD_AXIS_LEFTY),
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHTX), SDL_GAMEPAD_AXIS_RIGHTX),
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHTY), SDL_GAMEPAD_AXIS_RIGHTY),
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFT_TRIGGER), SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER), SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)
)
private fun buttonLabel(family: ControllerFamily, button: Int): String {
return when (button) {
SDL_GAMEPAD_BUTTON_DPAD_UP -> "D-Pad Up"
SDL_GAMEPAD_BUTTON_DPAD_DOWN -> "D-Pad Down"
SDL_GAMEPAD_BUTTON_DPAD_LEFT -> "D-Pad Left"
SDL_GAMEPAD_BUTTON_DPAD_RIGHT -> "D-Pad Right"
SDL_GAMEPAD_BUTTON_BACK -> when (family) {
ControllerFamily.Xbox -> "Back"
ControllerFamily.Nintendo -> "-"
ControllerFamily.Standard -> "Select"
}
SDL_GAMEPAD_BUTTON_GUIDE -> when (family) {
ControllerFamily.Xbox -> "Guide"
ControllerFamily.Nintendo -> "Home"
ControllerFamily.Standard -> "PS"
}
SDL_GAMEPAD_BUTTON_START -> when (family) {
ControllerFamily.Nintendo -> "+"
else -> "Start"
}
SDL_GAMEPAD_BUTTON_LEFT_STICK -> if (family == ControllerFamily.Standard) "L3" else "LS"
SDL_GAMEPAD_BUTTON_RIGHT_STICK -> if (family == ControllerFamily.Standard) "R3" else "RS"
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER -> when (family) {
ControllerFamily.Xbox -> "LB"
ControllerFamily.Nintendo -> "L"
ControllerFamily.Standard -> "L1"
}
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER -> when (family) {
ControllerFamily.Xbox -> "RB"
ControllerFamily.Nintendo -> "R"
ControllerFamily.Standard -> "R1"
}
SDL_GAMEPAD_BUTTON_SOUTH -> when (family) {
ControllerFamily.Xbox -> "A"
ControllerFamily.Nintendo -> "B"
ControllerFamily.Standard -> "Cross"
}
SDL_GAMEPAD_BUTTON_EAST -> when (family) {
ControllerFamily.Xbox -> "B"
ControllerFamily.Nintendo -> "A"
ControllerFamily.Standard -> "Circle"
}
SDL_GAMEPAD_BUTTON_WEST -> when (family) {
ControllerFamily.Xbox -> "X"
ControllerFamily.Nintendo -> "Y"
ControllerFamily.Standard -> "Square"
}
SDL_GAMEPAD_BUTTON_NORTH -> when (family) {
ControllerFamily.Xbox -> "Y"
ControllerFamily.Nintendo -> "X"
ControllerFamily.Standard -> "Triangle"
}
else -> "Button $button"
}
}
private fun axisLabel(family: ControllerFamily, axis: Int): String {
return when (axis) {
SDL_GAMEPAD_AXIS_LEFTX -> "Left X"
SDL_GAMEPAD_AXIS_LEFTY -> "Left Y"
SDL_GAMEPAD_AXIS_RIGHTX -> "Right X"
SDL_GAMEPAD_AXIS_RIGHTY -> "Right Y"
SDL_GAMEPAD_AXIS_LEFT_TRIGGER -> when (family) {
ControllerFamily.Xbox -> "LT"
ControllerFamily.Nintendo -> "ZL"
ControllerFamily.Standard -> "L2"
}
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER -> when (family) {
ControllerFamily.Xbox -> "RT"
ControllerFamily.Nintendo -> "ZR"
ControllerFamily.Standard -> "R2"
}
else -> "Axis $axis"
}
}
@@ -0,0 +1,149 @@
package org.vita3k.emulator.ui.screens.settings
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.os.Build
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import org.vita3k.emulator.overlay.InputOverlay
import org.vita3k.emulator.overlay.OverlayConfig
import org.vita3k.emulator.ui.components.OverlayEditorPalette
@Composable
internal fun OverlayLayoutEditorDialog(
overlayConfig: OverlayConfig,
onDismiss: () -> Unit,
layoutProfileId: String = ""
) {
BackHandler(onBack = onDismiss)
val activity = LocalContext.current.findActivity()
var overlayView by remember { mutableStateOf<InputOverlay?>(null) }
OverlayEditorWindowEffect(activity = activity)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
AndroidView(
factory = { context ->
InputOverlay(context).apply {
overlayView = this
setLayoutProfileId(layoutProfileId)
setAllowVirtualController(false)
setAutoHideEnabled(false)
setIsInEditMode(true)
setScale(overlayConfig.overlayScale / 100f)
setOpacity(overlayConfig.overlayOpacity)
setState(overlayConfig.activeMask())
}
},
update = { view ->
overlayView = view
view.setLayoutProfileId(layoutProfileId)
view.setAllowVirtualController(false)
view.setAutoHideEnabled(false)
view.setIsInEditMode(true)
view.setScale(overlayConfig.overlayScale / 100f)
view.setOpacity(overlayConfig.overlayOpacity)
view.setState(overlayConfig.activeMask())
},
modifier = Modifier.fillMaxSize()
)
OverlayEditorPalette(
onDone = onDismiss,
onReset = { overlayView?.resetButtonPlacement() }
)
}
}
@Composable
private fun OverlayEditorWindowEffect(activity: Activity?) {
DisposableEffect(activity) {
if (activity == null) {
return@DisposableEffect onDispose { }
}
val window = activity.window
val decorView = window.decorView
val previousOrientation = activity.requestedOrientation
val previousAttributes = WindowManager.LayoutParams().also { it.copyFrom(window.attributes) }
val systemBars = WindowInsetsCompat.Type.systemBars()
val barsWereVisible = ViewCompat.getRootWindowInsets(decorView)?.isVisible(systemBars) ?: true
val controller = WindowCompat.getInsetsController(window, decorView)
val focusListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
if (hasFocus) {
WindowCompat.getInsetsController(window, decorView)?.hide(systemBars)
}
}
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
WindowCompat.setDecorFitsSystemWindows(window, false)
controller?.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
)
controller?.hide(systemBars)
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
if (insets.isVisible(systemBars)) {
WindowCompat.getInsetsController(window, view)?.hide(systemBars)
}
insets
}
if (decorView.viewTreeObserver.isAlive) {
decorView.viewTreeObserver.addOnWindowFocusChangeListener(focusListener)
}
decorView.post {
WindowCompat.getInsetsController(window, decorView)?.hide(systemBars)
ViewCompat.requestApplyInsets(decorView)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val updatedAttributes = window.attributes
updatedAttributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes = updatedAttributes
}
onDispose {
ViewCompat.setOnApplyWindowInsetsListener(decorView, null)
if (decorView.viewTreeObserver.isAlive) {
decorView.viewTreeObserver.removeOnWindowFocusChangeListener(focusListener)
}
WindowCompat.setDecorFitsSystemWindows(window, true)
if (barsWereVisible) {
controller?.show(systemBars)
} else {
controller?.hide(systemBars)
}
window.attributes = previousAttributes
activity.requestedOrientation = previousOrientation
}
}
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@@ -0,0 +1,217 @@
package org.vita3k.emulator.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.ConnectedGamepad
import org.vita3k.emulator.R
import org.vita3k.emulator.data.EmulatorConfig
import org.vita3k.emulator.overlay.OverlayConfig
@Composable
internal fun OverlaySettingsSection(
cfg: EmulatorConfig,
overlayConfig: OverlayConfig,
isPerApp: Boolean,
connectedGamepads: List<ConnectedGamepad>,
onUpdate: (EmulatorConfig.() -> Unit) -> Unit,
onOverlayConfigChange: (OverlayConfig) -> Unit,
onShowHelp: (SettingsHelpEntry) -> Unit,
controllerConnected: Boolean = false,
onStartControlsEditor: (() -> Unit)? = null,
onResetControlsLayout: (() -> Unit)? = null
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsSectionCard(
title = stringResource(R.string.settings_tab_controls),
summary = null,
help = null,
onShowHelp = onShowHelp
) {
OverlaySettingsRows(
overlayConfig = overlayConfig,
onOverlayConfigChange = onOverlayConfigChange,
onShowHelp = onShowHelp,
controllerConnected = controllerConnected,
onStartControlsEditor = onStartControlsEditor,
onResetControlsLayout = onResetControlsLayout
)
}
if (!isPerApp) {
ControllerMappingSections(
cfg = cfg,
connectedGamepads = connectedGamepads,
onUpdate = onUpdate,
onShowHelp = onShowHelp
)
}
}
}
@Composable
internal fun OverlaySettingsRows(
overlayConfig: OverlayConfig,
onOverlayConfigChange: (OverlayConfig) -> Unit,
onShowHelp: (SettingsHelpEntry) -> Unit,
controllerConnected: Boolean = false,
onStartControlsEditor: (() -> Unit)? = null,
onResetControlsLayout: (() -> Unit)? = null
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (onStartControlsEditor != null || onResetControlsLayout != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onStartControlsEditor != null) {
FilledTonalButton(
onClick = onStartControlsEditor,
enabled = overlayConfig.controlsVisible,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.TouchApp, contentDescription = null)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
androidx.compose.material3.Text(stringResource(R.string.emulation_controls_edit))
}
}
if (onResetControlsLayout != null) {
OutlinedButton(
onClick = onResetControlsLayout,
enabled = overlayConfig.controlsVisible,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Restore, contentDescription = null)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
androidx.compose.material3.Text(stringResource(R.string.action_reset))
}
}
}
}
SettingsToggleRow(
title = stringResource(R.string.settings_controls_show),
checked = overlayConfig.controlsVisible,
onCheckedChange = { onOverlayConfigChange(overlayConfig.withControlsVisible(it)) },
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_show),
body = stringResource(R.string.settings_controls_show_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_hide_on_controller),
checked = overlayConfig.hideOverlayWhenControllerConnected,
onCheckedChange = {
onOverlayConfigChange(overlayConfig.copy(hideOverlayWhenControllerConnected = it))
},
summary = if (controllerConnected) {
stringResource(R.string.settings_controls_controller_connected)
} else {
stringResource(R.string.settings_controls_controller_disconnected)
},
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_hide_on_controller),
body = stringResource(R.string.settings_controls_hide_on_controller_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_l2r2),
checked = overlayConfig.l2r2Visible,
onCheckedChange = { onOverlayConfigChange(overlayConfig.withL2R2Visible(it)) },
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_l2r2),
body = stringResource(R.string.settings_controls_l2r2_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_l3r3),
checked = overlayConfig.l3r3Visible,
onCheckedChange = { onOverlayConfigChange(overlayConfig.withL3R3Visible(it)) },
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_l3r3),
body = stringResource(R.string.settings_controls_l3r3_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_touch_switch),
checked = overlayConfig.touchSwitchVisible,
onCheckedChange = { onOverlayConfigChange(overlayConfig.withTouchSwitchVisible(it)) },
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_touch_switch),
body = stringResource(R.string.settings_controls_touch_switch_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsToggleRow(
title = stringResource(R.string.settings_controls_hide_toggle),
checked = overlayConfig.hideToggleVisible,
onCheckedChange = { onOverlayConfigChange(overlayConfig.withHideToggleVisible(it)) },
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_hide_toggle),
body = stringResource(R.string.settings_controls_hide_toggle_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsSliderRow(
title = stringResource(R.string.settings_controls_scale),
valueLabel = stringResource(R.string.settings_controls_percent_value, overlayConfig.overlayScale),
value = overlayConfig.overlayScale.toFloat(),
onValueChange = {
onOverlayConfigChange(overlayConfig.copy(overlayScale = it.toInt()))
},
valueRange = 50f..150f,
steps = 99,
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_scale),
body = stringResource(R.string.settings_controls_scale_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
SettingsSliderRow(
title = stringResource(R.string.settings_controls_opacity),
valueLabel = stringResource(R.string.settings_controls_percent_value, overlayConfig.overlayOpacity),
value = overlayConfig.overlayOpacity.toFloat(),
onValueChange = {
onOverlayConfigChange(overlayConfig.copy(overlayOpacity = it.toInt()))
},
valueRange = 20f..100f,
steps = 79,
enabled = overlayConfig.controlsVisible,
help = SettingsHelpEntry(
title = stringResource(R.string.settings_controls_opacity),
body = stringResource(R.string.settings_controls_opacity_desc),
scope = SettingsScope.Global
),
onShowHelp = onShowHelp
)
}
}
@@ -0,0 +1,647 @@
package org.vita3k.emulator.ui.screens.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.vita3k.emulator.R
private val SectionShape = RoundedCornerShape(28.dp)
private val TileShape = RoundedCornerShape(20.dp)
@Composable
internal fun settingsFilterChipColors() = FilterChipDefaults.filterChipColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
selectedLabelColor = MaterialTheme.colorScheme.primary,
selectedLeadingIconColor = MaterialTheme.colorScheme.primary
)
@Composable
internal fun SettingsCategoryStrip(
categories: List<SettingsCategory>,
selectedCategory: SettingsCategory,
onCategorySelected: (SettingsCategory) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 0.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(categories, key = { it.name }) { category ->
FilterChip(
selected = category == selectedCategory,
onClick = { onCategorySelected(category) },
colors = settingsFilterChipColors(),
label = { Text(category.label()) },
leadingIcon = {
Icon(
imageVector = category.icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
}
}
}
@Composable
internal fun SettingsSectionCard(
title: String,
summary: String? = null,
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = SectionShape,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold)
)
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
help?.let {
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
}
}
content()
}
}
}
@Composable
internal fun SettingsSubsectionTitle(
title: String,
summary: String? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold)
)
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
internal fun SettingsToggleRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = null,
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val valueLabel = summary ?: androidx.compose.ui.res.stringResource(
if (checked) R.string.settings_value_enabled else R.string.settings_value_disabled
)
SettingsRowContainer(
title = title,
summary = valueLabel,
modifier = modifier,
enabled = enabled,
onClick = { if (enabled) onCheckedChange(!checked) },
help = help,
onShowHelp = onShowHelp
) {
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SettingsChoiceSelector(
title: String,
options: List<String>,
selectedIndex: Int,
onSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = options.getOrNull(selectedIndex.coerceAtLeast(0)),
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val clampedSelectedIndex = if (options.isEmpty()) 0 else selectedIndex.coerceIn(0, options.lastIndex)
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
shape = TileShape
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
)
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
help?.let {
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
}
}
if (options.isNotEmpty()) {
SettingsChoiceChips(
options = options,
selectedIndex = clampedSelectedIndex,
onSelected = onSelected,
modifier = Modifier.padding(top = 12.dp)
)
}
}
}
}
@Composable
internal fun SettingsScrollableChoiceSelector(
title: String,
options: List<String>,
selectedIndex: Int,
onSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = options.getOrNull(selectedIndex.coerceAtLeast(0)),
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val clampedSelectedIndex = if (options.isEmpty()) 0 else selectedIndex.coerceIn(0, options.lastIndex)
var expanded by remember { mutableStateOf(false) }
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
shape = TileShape
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
)
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
help?.let {
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
}
}
if (options.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
OutlinedTextField(
value = options[clampedSelectedIndex],
onValueChange = {},
readOnly = true,
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyMedium,
trailingIcon = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.rotate(if (expanded) 90f else 0f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f),
focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null
) { expanded = true }
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.heightIn(max = 320.dp)
) {
options.forEachIndexed { index, option ->
DropdownMenuItem(
text = {
Text(
text = option,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
expanded = false
onSelected(index)
}
)
}
}
}
}
}
}
}
@Composable
internal fun SettingsActionRow(
title: String,
value: String? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
actionLabel: String? = null,
onActionClick: (() -> Unit)? = null,
actionEnabled: Boolean = true,
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
SettingsRowContainer(
title = title,
summary = value,
modifier = modifier,
enabled = enabled,
onClick = onClick,
help = help,
onShowHelp = onShowHelp
) {
if (!actionLabel.isNullOrBlank() && onActionClick != null) {
TextButton(
onClick = onActionClick,
enabled = enabled && actionEnabled
) {
Text(actionLabel)
}
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
internal fun SettingsSliderRow(
title: String,
valueLabel: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
modifier: Modifier = Modifier,
steps: Int = 0,
enabled: Boolean = true,
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
shape = TileShape
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = valueLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
help?.let {
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
}
}
androidx.compose.material3.Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
enabled = enabled,
interactionSource = interactionSource,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
}
}
}
@Composable
internal fun SettingsNote(
text: String,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = color,
modifier = modifier.fillMaxWidth()
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SettingsChoiceChips(
options: List<String>,
selectedIndex: Int,
onSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
options.forEachIndexed { index, option ->
FilterChip(
selected = index == selectedIndex,
onClick = { onSelected(index) },
colors = settingsFilterChipColors(),
label = { Text(option) }
)
}
}
}
@Composable
internal fun SettingsSearchResultRow(
entry: SettingsSearchEntry,
onOpen: (SettingsCategory) -> Unit,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
SettingsActionRow(
title = entry.title,
value = entry.summary,
onClick = { onOpen(entry.category) },
help = entry.help,
onShowHelp = onShowHelp
)
}
@Composable
internal fun SettingsHelpSheet(
help: SettingsHelpEntry,
onDismiss: () -> Unit
) {
val scrollState = rememberScrollState()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(help.title, style = MaterialTheme.typography.headlineSmall) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
help.scope.labelRes?.let { labelRes ->
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(999.dp)
) {
Text(
text = androidx.compose.ui.res.stringResource(labelRes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
Text(
text = help.body,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(androidx.compose.ui.res.stringResource(R.string.action_ok))
}
}
)
}
@Composable
internal fun SettingsLoadingState(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
internal fun SettingsHelpButton(
help: SettingsHelpEntry,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
IconButton(onClick = { onShowHelp(help) }) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = androidx.compose.ui.res.stringResource(R.string.settings_cd_show_info),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SettingsRowContainer(
title: String,
summary: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
help: SettingsHelpEntry? = null,
onShowHelp: (SettingsHelpEntry) -> Unit,
trailing: @Composable RowScope.() -> Unit
) {
val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f)
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier
.fillMaxWidth()
.clip(TileShape)
.then(
if (onClick != null) {
Modifier.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
} else {
Modifier
}
),
shape = TileShape,
color = containerColor
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
)
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
help?.let {
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
}
trailing()
}
}
}
}
@@ -0,0 +1,68 @@
package org.vita3k.emulator.ui.screens.settings
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SettingsSuggest
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Tune
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import org.vita3k.emulator.R
internal enum class SettingsScope(@StringRes val labelRes: Int?) {
Both(labelRes = null),
Global(labelRes = R.string.settings_scope_global),
PerApp(labelRes = R.string.settings_scope_per_app)
}
internal enum class SettingsCategory(
@StringRes val labelRes: Int,
val icon: ImageVector
) {
Core(R.string.settings_tab_core, Icons.Default.Tune),
Cpu(R.string.settings_tab_cpu, Icons.Default.Speed),
Gpu(R.string.settings_tab_gpu, Icons.Default.GraphicEq),
Audio(R.string.settings_tab_audio, Icons.AutoMirrored.Filled.VolumeUp),
Camera(R.string.settings_tab_camera, Icons.Default.PhotoCamera),
System(R.string.settings_tab_system, Icons.Default.Language),
Controls(R.string.settings_tab_controls, Icons.Default.TouchApp),
Interface(R.string.settings_tab_interface, Icons.Default.Settings),
Emulator(R.string.settings_tab_emulator, Icons.Default.SettingsSuggest),
Network(R.string.settings_tab_network, Icons.Default.Public),
Debug(R.string.settings_tab_debug, Icons.Default.BugReport)
}
internal fun settingsCategories(isPerApp: Boolean): List<SettingsCategory> {
return if (isPerApp) {
SettingsCategory.entries.filter { it != SettingsCategory.Camera && it != SettingsCategory.Interface }
} else {
SettingsCategory.entries.toList()
}
}
@Composable
internal fun SettingsCategory.label(): String = stringResource(labelRes)
internal data class SettingsHelpEntry(
val title: String,
val body: String,
val scope: SettingsScope = SettingsScope.Both
)
internal data class SettingsSearchEntry(
val category: SettingsCategory,
val title: String,
val summary: String,
val keywords: String,
val help: SettingsHelpEntry? = null
)
@@ -0,0 +1,777 @@
package org.vita3k.emulator.ui.screens.settings
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import org.vita3k.emulator.MainActivity
import org.vita3k.emulator.R
import org.vita3k.emulator.data.CustomDriverLoadStatus
import org.vita3k.emulator.data.RestartRequiredSetting
import org.vita3k.emulator.overlay.OverlayConfig
import org.vita3k.emulator.overlay.OverlayConfigStore
import org.vita3k.emulator.overlay.OverlayLayoutStore
import org.vita3k.emulator.ui.CUSTOM_DRIVER_DOWNLOAD_URL
import org.vita3k.emulator.ui.rememberConnectedGamepads
import org.vita3k.emulator.ui.theme.ApplyDialogDim
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
private val CUSTOM_DRIVER_MIME_TYPES = arrayOf(
"application/zip",
"application/x-zip-compressed",
"application/octet-stream"
)
private val IMAGE_MIME_TYPES = arrayOf("image/*")
private fun formatRestartRequiredSettings(
context: android.content.Context,
settings: List<RestartRequiredSetting>
): String = settings.joinToString(separator = "\n- ", prefix = "- ") { setting ->
context.getString(setting.labelResId)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsRoute(
titleId: String?,
appName: String? = null,
viewModel: SettingsViewModel,
onStorageChanged: () -> Unit = {},
onBack: () -> Unit
) {
LaunchedEffect(titleId) {
if (!viewModel.isLoaded(titleId)) {
viewModel.load(titleId)
}
}
val context = LocalContext.current
val isPerApp = titleId != null
val settingsLoaded = viewModel.isLoaded(titleId)
val categories = remember(isPerApp) { settingsCategories(isPerApp) }
val scrollState = rememberScrollState()
val overlayLayoutProfileId = titleId.orEmpty()
var overlayConfig by remember(titleId) { mutableStateOf(OverlayConfigStore.load(context)) }
val connectedGamepads = rememberConnectedGamepads(context)
val controllerConnected = connectedGamepads.isNotEmpty()
var selectedCategory by rememberSaveable(titleId) { mutableStateOf(SettingsCategory.Core) }
var searchQuery by rememberSaveable(titleId) { mutableStateOf("") }
var showDiscardDialog by remember { mutableStateOf(false) }
var showResetDefaultsDialog by remember { mutableStateOf(false) }
var showDeleteCustomConfigDialog by remember { mutableStateOf(false) }
var showClearAllCustomConfigsDialog by remember { mutableStateOf(false) }
var pendingCustomDriverRemoval by remember { mutableStateOf<String?>(null) }
var pendingCameraImageTarget by remember { mutableStateOf<Boolean?>(null) }
var activeHelp by remember { mutableStateOf<SettingsHelpEntry?>(null) }
var searchActive by rememberSaveable(titleId) { mutableStateOf(false) }
var showOverflowMenu by remember { mutableStateOf(false) }
var showOverlayEditor by remember(titleId) { mutableStateOf(false) }
var pendingRestartSettings by remember { mutableStateOf<List<RestartRequiredSetting>?>(null) }
var closeAfterRestartNotice by remember { mutableStateOf(false) }
fun requestStorageFolderChange() {
val activity = context as? MainActivity ?: return
activity.requestStorageFolderChange { path ->
path?.let { viewModel.changeStorageFolder(it, onStorageChanged) }
}
}
fun requestCustomDriverInstall() {
val activity = context as? MainActivity ?: return
activity.requestFilePath(CUSTOM_DRIVER_MIME_TYPES) { path ->
path?.let(viewModel::installCustomDriver)
}
}
fun closeSearch() {
searchActive = false
searchQuery = ""
}
fun pickCameraImage(isFront: Boolean) {
val activity = context as? MainActivity ?: return
pendingCameraImageTarget = isFront
activity.requestFilePath(IMAGE_MIME_TYPES) { path ->
val target = pendingCameraImageTarget
pendingCameraImageTarget = null
if (target != null && path != null) {
viewModel.setCameraImage(target, path)
}
}
}
fun openUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
fun tryBack() {
if (viewModel.isDirty) showDiscardDialog = true else onBack()
}
BackHandler { tryBack() }
LaunchedEffect(categories, selectedCategory) {
if (selectedCategory !in categories) {
selectedCategory = categories.firstOrNull() ?: SettingsCategory.Core
}
}
LaunchedEffect(selectedCategory, searchQuery) {
scrollState.scrollTo(0)
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
title = {
if (searchActive) {
SettingsTopBarSearchField(
query = searchQuery,
onQueryChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth()
)
} else {
Text(
if (isPerApp && appName != null) {
stringResource(R.string.settings_custom_config_label, appName)
} else {
stringResource(R.string.settings_title)
}
)
}
},
navigationIcon = {
IconButton(onClick = { tryBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
},
actions = {
if (!searchActive && viewModel.isDirty && !viewModel.saving && !viewModel.customDriverBusy) {
IconButton(onClick = {
viewModel.save(forceCustomConfig = isPerApp) { restartRequired ->
if (restartRequired.isNotEmpty()) {
pendingRestartSettings = restartRequired
}
}
}) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.settings_save_cd)
)
}
}
IconButton(onClick = {
if (searchActive) {
closeSearch()
} else {
searchActive = true
}
}) {
Icon(
imageVector = if (searchActive) Icons.Default.Close else Icons.Default.Search,
contentDescription = if (searchActive) {
stringResource(R.string.apps_list_cd_close_search)
} else {
stringResource(R.string.apps_list_cd_search)
}
)
}
SettingsOverflowMenu(
expanded = showOverflowMenu,
onExpandedChange = { showOverflowMenu = it },
showDeleteCustomConfig = isPerApp && viewModel.hasCustomConfig,
onResetDefaults = {
showOverflowMenu = false
showResetDefaultsDialog = true
},
onDeleteCustomConfig = {
showOverflowMenu = false
showDeleteCustomConfigDialog = true
}
)
}
)
}
) { padding ->
if (!settingsLoaded) {
SettingsLoadingState(
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
return@Scaffold
}
val searchEntries = rememberSettingsSearchEntries(
isPerApp = isPerApp,
backendRenderer = viewModel.config.backendRenderer,
showCustomDriverOptions = viewModel.showCustomDriverOptions,
showTurboModeOption = viewModel.showCustomDriverOptions
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
SettingsContentPane(
modifier = Modifier.fillMaxSize(),
titleId = titleId,
isPerApp = isPerApp,
viewModel = viewModel,
overlayConfig = overlayConfig,
supportedMemoryMappingMask = viewModel.supportedMemoryMappingMask,
customDriverLoadStatus = viewModel.customDriverLoadStatus,
availableCameras = viewModel.availableCameras,
availableAdhocAddresses = viewModel.availableAdhocAddresses,
selectedCategory = selectedCategory,
searchQuery = searchQuery,
onCloseSearch = ::closeSearch,
onSelectedCategoryChange = { selectedCategory = it },
searchEntries = searchEntries,
currentStoragePath = viewModel.currentStoragePath,
scrollState = scrollState,
controllerConnected = controllerConnected,
connectedGamepads = connectedGamepads,
categories = categories,
onChangeStorageFolder = ::requestStorageFolderChange,
onResetStorageFolder = { viewModel.resetStorageFolder(onStorageChanged) },
onInstallCustomDriver = ::requestCustomDriverInstall,
onDownloadCustomDriver = { openUrl(CUSTOM_DRIVER_DOWNLOAD_URL) },
onPickCameraImage = ::pickCameraImage,
onRequestRemoveCustomDriver = { pendingCustomDriverRemoval = it },
onClearAllCustomConfigs = { showClearAllCustomConfigsDialog = true },
onOverlayConfigChange = { updated ->
overlayConfig = updated.normalized()
OverlayConfigStore.save(context, overlayConfig)
},
onStartControlsEditor = {
showOverlayEditor = true
},
onResetControlsLayout = {
OverlayLayoutStore.resetToDefaults(context, overlayLayoutProfileId)
overlayConfig = OverlayConfigStore.load(context)
},
onShowHelp = { activeHelp = it },
)
}
}
if (showOverlayEditor) {
OverlayLayoutEditorDialog(
overlayConfig = overlayConfig,
layoutProfileId = overlayLayoutProfileId,
onDismiss = {
showOverlayEditor = false
overlayConfig = OverlayConfigStore.load(context)
}
)
}
}
if (showDiscardDialog) {
SettingsUnsavedChangesDialog(
saving = viewModel.saving,
onCancel = { showDiscardDialog = false },
onSaveAndExit = {
showDiscardDialog = false
viewModel.save(forceCustomConfig = isPerApp) { restartRequired ->
if (restartRequired.isEmpty()) {
onBack()
} else {
closeAfterRestartNotice = true
pendingRestartSettings = restartRequired
}
}
},
onDiscard = {
showDiscardDialog = false
viewModel.discardChanges()
onBack()
}
)
}
if (showResetDefaultsDialog) {
AlertDialog(
onDismissRequest = { showResetDefaultsDialog = false },
title = { Text(stringResource(R.string.settings_confirm_reset_defaults_title)) },
text = {
ApplyDialogDim()
Text(stringResource(R.string.settings_confirm_reset_defaults_message))
},
confirmButton = {
TextButton(onClick = {
showResetDefaultsDialog = false
viewModel.resetToDefaults()
}) {
Text(stringResource(R.string.settings_reset_defaults))
}
},
dismissButton = {
TextButton(onClick = { showResetDefaultsDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showDeleteCustomConfigDialog) {
AlertDialog(
onDismissRequest = { showDeleteCustomConfigDialog = false },
title = { Text(stringResource(R.string.settings_confirm_delete_custom_config_title)) },
text = {
ApplyDialogDim()
Text(
stringResource(
R.string.settings_confirm_delete_custom_config_message,
appName ?: titleId ?: ""
)
)
},
confirmButton = {
TextButton(onClick = {
showDeleteCustomConfigDialog = false
viewModel.deleteCustomConfig { restartRequired ->
if (restartRequired.isNotEmpty()) {
pendingRestartSettings = restartRequired
}
}
}) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { showDeleteCustomConfigDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showClearAllCustomConfigsDialog) {
AlertDialog(
onDismissRequest = { showClearAllCustomConfigsDialog = false },
title = { Text(stringResource(R.string.settings_confirm_clear_all_title)) },
text = {
ApplyDialogDim()
Text(stringResource(R.string.settings_confirm_clear_all_message))
},
confirmButton = {
TextButton(onClick = {
showClearAllCustomConfigsDialog = false
viewModel.clearAllCustomConfigs()
}) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { showClearAllCustomConfigsDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
pendingCustomDriverRemoval?.let { driverName ->
AlertDialog(
onDismissRequest = { pendingCustomDriverRemoval = null },
title = { Text(stringResource(R.string.settings_confirm_remove_custom_driver_title)) },
text = {
ApplyDialogDim()
Text(stringResource(R.string.settings_confirm_remove_custom_driver_message, driverName))
},
confirmButton = {
TextButton(onClick = {
pendingCustomDriverRemoval = null
viewModel.removeCustomDriver(driverName)
}) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { pendingCustomDriverRemoval = null }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
viewModel.operationResult?.let { result ->
AlertDialog(
onDismissRequest = { viewModel.dismissOperationResult() },
title = {
Text(
if (result.isError) {
stringResource(R.string.settings_opt_error)
} else {
stringResource(R.string.settings_success_title)
}
)
},
text = {
ApplyDialogDim()
Text(result.message)
},
confirmButton = {
TextButton(onClick = { viewModel.dismissOperationResult() }) {
Text(stringResource(R.string.action_ok))
}
}
)
}
pendingRestartSettings?.let { settings ->
AlertDialog(
onDismissRequest = { },
title = { Text(stringResource(R.string.settings_restart_required_title)) },
text = {
ApplyDialogDim()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.settings_restart_required_summary))
Text(
stringResource(
R.string.settings_restart_required_message,
formatRestartRequiredSettings(context, settings)
)
)
}
},
confirmButton = {
TextButton(onClick = {
pendingRestartSettings = null
val shouldClose = closeAfterRestartNotice
closeAfterRestartNotice = false
if (shouldClose) {
onBack()
}
}) {
Text(stringResource(R.string.action_ok))
}
}
)
}
activeHelp?.let { help ->
SettingsHelpSheet(help = help, onDismiss = { activeHelp = null })
}
}
@Composable
private fun SettingsContentPane(
modifier: Modifier,
titleId: String?,
isPerApp: Boolean,
viewModel: SettingsViewModel,
overlayConfig: OverlayConfig,
supportedMemoryMappingMask: Int,
customDriverLoadStatus: CustomDriverLoadStatus,
availableCameras: List<String>,
availableAdhocAddresses: List<Pair<String, String>>,
selectedCategory: SettingsCategory,
searchQuery: String,
onCloseSearch: () -> Unit,
onSelectedCategoryChange: (SettingsCategory) -> Unit,
searchEntries: List<SettingsSearchEntry>,
currentStoragePath: String,
scrollState: androidx.compose.foundation.ScrollState,
controllerConnected: Boolean,
connectedGamepads: List<org.vita3k.emulator.ConnectedGamepad>,
categories: List<SettingsCategory>,
onChangeStorageFolder: () -> Unit,
onResetStorageFolder: () -> Unit,
onInstallCustomDriver: () -> Unit,
onDownloadCustomDriver: () -> Unit,
onPickCameraImage: (Boolean) -> Unit,
onRequestRemoveCustomDriver: (String) -> Unit,
onClearAllCustomConfigs: () -> Unit,
onOverlayConfigChange: (OverlayConfig) -> Unit,
onStartControlsEditor: (() -> Unit)?,
onResetControlsLayout: (() -> Unit)?,
onShowHelp: (SettingsHelpEntry) -> Unit,
) {
Column(
modifier = modifier
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)
) {
if (isPerApp && viewModel.hasCustomConfig) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer,
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp)
) {
Text(
text = stringResource(R.string.settings_custom_config_banner),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
SettingsCategoryStrip(
categories = categories,
selectedCategory = selectedCategory,
onCategorySelected = onSelectedCategoryChange
)
if (searchQuery.isBlank()) {
SettingsCategoryBody(
category = selectedCategory,
cfg = viewModel.config,
overlayConfig = overlayConfig,
modulesList = viewModel.modulesList,
modulesSearch = viewModel.modulesSearch,
onModulesSearchChange = viewModel::onModulesSearchChange,
onToggleModule = viewModel::toggleModule,
supportedMemoryMappingMask = supportedMemoryMappingMask,
customDriverLoadStatus = customDriverLoadStatus,
availableCameras = availableCameras,
availableAdhocAddresses = availableAdhocAddresses,
currentStoragePath = currentStoragePath,
defaultStoragePath = viewModel.defaultStoragePath,
onChangeStorageFolder = onChangeStorageFolder,
onResetStorageFolder = onResetStorageFolder,
installedCustomDrivers = viewModel.installedCustomDrivers,
customDriverBusy = viewModel.customDriverBusy,
onInstallCustomDriver = onInstallCustomDriver,
onDownloadCustomDriver = onDownloadCustomDriver,
onPickCameraImage = onPickCameraImage,
onRequestRemoveCustomDriver = onRequestRemoveCustomDriver,
isPerApp = isPerApp,
showCustomDriverManagement = viewModel.showCustomDriverOptions,
showTurboModeOption = viewModel.showCustomDriverOptions,
onClearAllCustomConfigs = onClearAllCustomConfigs,
onSelectCustomDriver = viewModel::selectCustomDriver,
onUpdate = viewModel::update,
onOverlayConfigChange = onOverlayConfigChange,
onStartControlsEditor = onStartControlsEditor,
onResetControlsLayout = onResetControlsLayout,
controllerConnected = controllerConnected,
connectedGamepads = connectedGamepads,
onShowHelp = onShowHelp
)
} else {
SettingsSearchResults(
query = searchQuery,
entries = searchEntries,
onOpen = {
onSelectedCategoryChange(it)
onCloseSearch()
},
onShowHelp = onShowHelp
)
}
if (titleId == null && viewModel.isDirty) {
Spacer(modifier = Modifier.width(1.dp))
}
}
}
@Composable
private fun SettingsOverflowMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
showDeleteCustomConfig: Boolean,
onResetDefaults: () -> Unit,
onDeleteCustomConfig: () -> Unit
) {
Box {
IconButton(onClick = { onExpandedChange(true) }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.settings_more_options)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_reset_defaults)) },
onClick = onResetDefaults
)
if (showDeleteCustomConfig) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_delete_custom_config)) },
onClick = onDeleteCustomConfig
)
}
}
}
}
@Composable
private fun SettingsTopBarSearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val textStyle = MaterialTheme.typography.bodyLarge.merge(
TextStyle(color = MaterialTheme.colorScheme.onSurface)
)
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
shape = RoundedCornerShape(22.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 10.dp)
) {
if (query.isBlank()) {
Text(
text = stringResource(R.string.settings_search_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = textStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun SettingsUnsavedChangesDialog(
saving: Boolean,
onCancel: () -> Unit,
onSaveAndExit: () -> Unit,
onDiscard: () -> Unit
) {
Dialog(onDismissRequest = onCancel) {
ApplyDialogDim()
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.settings_unsaved_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.settings_unsaved_message),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onCancel, enabled = !saving) {
Text(stringResource(R.string.action_cancel))
}
TextButton(onClick = onDiscard, enabled = !saving) {
Text(
text = stringResource(R.string.settings_discard),
color = MaterialTheme.colorScheme.error
)
}
TextButton(onClick = onSaveAndExit, enabled = !saving) {
Text(stringResource(R.string.settings_save_and_exit))
}
}
}
}
}
}
@@ -0,0 +1,188 @@
package org.vita3k.emulator.ui.screens.settings
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import org.vita3k.emulator.R
@Composable
internal fun rememberSettingsSearchEntries(
isPerApp: Boolean,
backendRenderer: String,
showCustomDriverOptions: Boolean,
showTurboModeOption: Boolean = showCustomDriverOptions
): List<SettingsSearchEntry> {
val context = LocalContext.current
val configuration = LocalConfiguration.current
fun help(
@StringRes titleRes: Int,
@StringRes bodyRes: Int,
scope: SettingsScope = SettingsScope.Both
): SettingsHelpEntry {
return SettingsHelpEntry(
title = context.getString(titleRes),
body = context.getString(bodyRes),
scope = scope
)
}
fun entry(
category: SettingsCategory,
@StringRes titleRes: Int,
@StringRes bodyRes: Int? = null,
scope: SettingsScope = SettingsScope.Both,
keywords: String = ""
): SettingsSearchEntry {
val title = context.getString(titleRes)
val categoryLabel = context.getString(category.labelRes)
val helpEntry = bodyRes?.let { help(titleRes, it, scope) }
val searchableBody = bodyRes?.let { context.getString(it) }.orEmpty()
return SettingsSearchEntry(
category = category,
title = title,
summary = categoryLabel,
keywords = listOf(title, categoryLabel, searchableBody, keywords).joinToString(" "),
help = helpEntry
)
}
val isVulkan = remember(backendRenderer) { isVulkanBackend(backendRenderer) }
return remember(context, configuration, isPerApp, isVulkan, showCustomDriverOptions, showTurboModeOption) { buildList {
add(entry(SettingsCategory.Core, R.string.settings_modules_mode, R.string.settings_modules_automatic_desc))
add(entry(SettingsCategory.Core, R.string.settings_modules_list_title, keywords = "lle modules firmware"))
add(entry(SettingsCategory.Cpu, R.string.settings_cpu_opt, R.string.settings_cpu_opt_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_backend, R.string.settings_gpu_backend_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_accuracy, R.string.settings_gpu_accuracy_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_vsync, R.string.settings_gpu_vsync_desc))
if (isVulkan) {
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_disable_surface_sync, R.string.settings_gpu_disable_surface_sync_desc))
}
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_async_pipeline, R.string.settings_gpu_async_pipeline_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_screen_filter, R.string.settings_gpu_screen_filter_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_resolution, R.string.settings_gpu_resolution_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_anisotropic, R.string.settings_gpu_anisotropic_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_export_textures, R.string.settings_gpu_export_textures_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_texture_format, R.string.settings_gpu_texture_format_desc, keywords = "png dds export textures"))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_import_textures, R.string.settings_gpu_import_textures_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_shader_cache, R.string.settings_gpu_shader_cache_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_spirv_shader, R.string.settings_gpu_spirv_shader_desc))
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_fps_hack, R.string.settings_gpu_fps_hack_desc, keywords = "fps framerate 30 60 game hack"))
if (!isPerApp && showTurboModeOption) {
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_turbo_mode, R.string.settings_gpu_turbo_mode_desc, scope = SettingsScope.Global, keywords = "gpu clocks adreno thermal"))
}
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_memory_mapping, R.string.settings_gpu_memory_mapping_desc))
if (showCustomDriverOptions) {
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_custom_driver, R.string.settings_gpu_custom_driver_desc, keywords = "adreno driver zip"))
}
add(entry(SettingsCategory.Audio, R.string.settings_audio_backend, R.string.settings_audio_backend_desc))
add(entry(SettingsCategory.Audio, R.string.settings_audio_volume, R.string.settings_audio_volume_desc))
add(entry(SettingsCategory.Audio, R.string.settings_audio_ngs, R.string.settings_audio_ngs_desc))
if (!isPerApp) {
add(entry(SettingsCategory.Camera, R.string.settings_camera_front, R.string.settings_camera_front_desc, scope = SettingsScope.Global, keywords = "camera source solid color static image"))
add(entry(SettingsCategory.Camera, R.string.settings_camera_back, R.string.settings_camera_back_desc, scope = SettingsScope.Global, keywords = "camera source solid color static image"))
add(entry(SettingsCategory.Camera, R.string.settings_camera_image, R.string.settings_camera_image_desc, scope = SettingsScope.Global, keywords = "front back camera picture image"))
add(entry(SettingsCategory.Camera, R.string.settings_camera_color, R.string.settings_camera_color_desc, scope = SettingsScope.Global, keywords = "front back camera color red green blue alpha"))
}
add(entry(SettingsCategory.System, R.string.settings_system_pstv_mode, R.string.settings_system_pstv_mode_desc))
if (!isPerApp) {
add(entry(SettingsCategory.System, R.string.settings_system_show_mode, R.string.settings_system_show_mode_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.System, R.string.settings_system_demo_mode, R.string.settings_system_demo_mode_desc, scope = SettingsScope.Global))
}
add(entry(SettingsCategory.System, R.string.settings_system_enter_button, R.string.settings_system_enter_button_desc))
add(entry(SettingsCategory.System, R.string.settings_system_language, R.string.settings_system_language_desc))
add(entry(SettingsCategory.System, R.string.settings_system_date_format, R.string.settings_system_date_format_desc))
add(entry(SettingsCategory.System, R.string.settings_system_time_format, R.string.settings_system_time_format_desc))
add(entry(SettingsCategory.System, R.string.settings_system_ime_langs, R.string.settings_system_ime_langs_desc))
add(entry(SettingsCategory.Controls, R.string.settings_controls_show, R.string.settings_controls_show_desc, scope = SettingsScope.Global, keywords = "overlay touch buttons"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_hide_on_controller, R.string.settings_controls_hide_on_controller_desc, scope = SettingsScope.Global, keywords = "gamepad controller overlay hide"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_l2r2, R.string.settings_controls_l2r2_desc, scope = SettingsScope.Global, keywords = "touch triggers shoulder buttons"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_l3r3, R.string.settings_controls_l3r3_desc, scope = SettingsScope.Global, keywords = "touch stick clicks l3 r3"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_touch_switch, R.string.settings_controls_touch_switch_desc, scope = SettingsScope.Global, keywords = "front rear touchscreen switch"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_hide_toggle, R.string.settings_controls_hide_toggle_desc, scope = SettingsScope.Global, keywords = "hide overlay button toggle"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_scale, R.string.settings_controls_scale_desc, scope = SettingsScope.Global, keywords = "overlay size"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_opacity, R.string.settings_controls_opacity_desc, scope = SettingsScope.Global, keywords = "overlay transparency"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_mapping_buttons_title, R.string.settings_controls_mapping_buttons_desc, scope = SettingsScope.Global, keywords = "controller remap mapping buttons gamepad"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_mapping_axes_title, R.string.settings_controls_mapping_axes_desc, scope = SettingsScope.Global, keywords = "controller remap sticks triggers axes gamepad"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_analog_multiplier, R.string.settings_controls_analog_multiplier_desc, scope = SettingsScope.Global, keywords = "analog stick sensitivity multiplier"))
add(entry(SettingsCategory.Controls, R.string.settings_controls_disable_motion, R.string.settings_controls_disable_motion_desc, scope = SettingsScope.Global, keywords = "gyro accelerometer motion controls"))
add(entry(SettingsCategory.Network, R.string.settings_network_psn_signed_in, R.string.settings_network_psn_signed_in_desc, scope = SettingsScope.PerApp))
if (!isPerApp) {
add(entry(SettingsCategory.Network, R.string.settings_network_http_enable, R.string.settings_network_http_enable_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Network, R.string.settings_network_http_timeout_attempts, R.string.settings_network_http_timeout_attempts_desc, scope = SettingsScope.Global, keywords = "retry timeout http"))
add(entry(SettingsCategory.Network, R.string.settings_network_http_timeout_sleep, R.string.settings_network_http_timeout_sleep_desc, scope = SettingsScope.Global, keywords = "retry delay timeout http ms"))
add(entry(SettingsCategory.Network, R.string.settings_network_http_read_end_attempts, R.string.settings_network_http_read_end_attempts_desc, scope = SettingsScope.Global, keywords = "read end retry http"))
add(entry(SettingsCategory.Network, R.string.settings_network_http_read_end_sleep, R.string.settings_network_http_read_end_sleep_desc, scope = SettingsScope.Global, keywords = "read end delay http ms"))
add(entry(SettingsCategory.Network, R.string.settings_network_adhoc_address, R.string.settings_network_adhoc_address_desc, scope = SettingsScope.Global, keywords = "adhoc subnet local address"))
}
if (!isPerApp) {
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_imports, R.string.settings_debug_log_imports_desc, scope = SettingsScope.Global, keywords = "import symbols hle"))
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_exports, R.string.settings_debug_log_exports_desc, scope = SettingsScope.Global, keywords = "export symbols hle"))
}
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_active_shaders, R.string.settings_debug_log_active_shaders_desc))
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_uniforms, R.string.settings_debug_log_uniforms_desc))
add(entry(SettingsCategory.Debug, R.string.settings_debug_color_surface, R.string.settings_debug_color_surface_desc))
add(entry(SettingsCategory.Debug, R.string.settings_debug_validation_layer, R.string.settings_debug_validation_layer_desc))
if (!isPerApp) {
add(entry(SettingsCategory.Debug, R.string.settings_debug_dump_elfs, R.string.settings_debug_dump_elfs_desc, scope = SettingsScope.Global, keywords = "elf dump loaded code"))
}
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_texture_cache, R.string.settings_emulator_texture_cache_desc))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_stretch_display, R.string.settings_emulator_stretch_display_desc))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_pixel_perfect, R.string.settings_emulator_pixel_perfect_desc))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_file_loading_delay, R.string.settings_emulator_file_loading_delay_desc))
if (!isPerApp) {
add(entry(SettingsCategory.Interface, R.string.settings_emulator_ui_language, R.string.settings_emulator_ui_language_desc, scope = SettingsScope.Global, keywords = "interface ui app language locale"))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_storage_folder, R.string.settings_emulator_storage_folder_desc, scope = SettingsScope.Global, keywords = "storage folder path data directory"))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_show_compile_shaders, R.string.settings_emulator_show_compile_shaders_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_check_updates, R.string.settings_emulator_check_updates_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_archive_log, R.string.settings_emulator_archive_log_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_log_compat_warn, R.string.settings_emulator_log_compat_warn_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_log_level, R.string.settings_emulator_log_level_desc, scope = SettingsScope.Global, keywords = "logging trace debug info warning error critical off"))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay, R.string.settings_emulator_perf_overlay_desc, scope = SettingsScope.Global))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay_detail, R.string.settings_emulator_perf_overlay_detail_desc, scope = SettingsScope.Global, keywords = "minimum low medium maximum"))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay_position, R.string.settings_emulator_perf_overlay_position_desc, scope = SettingsScope.Global, keywords = "top bottom left center right"))
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_screenshot_format, R.string.settings_emulator_screenshot_format_desc, scope = SettingsScope.Global, keywords = "jpeg png none"))
}
} }
}
@Composable
internal fun SettingsSearchResults(
query: String,
entries: List<SettingsSearchEntry>,
onOpen: (SettingsCategory) -> Unit,
onShowHelp: (SettingsHelpEntry) -> Unit
) {
val normalizedQuery = remember(query) { normalizeSettingsSearchToken(query) }
val filtered = remember(entries, normalizedQuery) {
entries.filter { entry ->
normalizeSettingsSearchToken(entry.keywords).contains(normalizedQuery)
}
}
SettingsSectionCard(
title = stringResource(R.string.settings_search_results),
summary = if (filtered.isEmpty()) stringResource(R.string.settings_search_no_results) else null,
help = null,
onShowHelp = onShowHelp
) {
if (filtered.isEmpty()) {
SettingsNote(text = stringResource(R.string.settings_search_no_results))
} else {
filtered.forEach { entry ->
SettingsSearchResultRow(entry = entry, onOpen = onOpen, onShowHelp = onShowHelp)
}
}
}
}
private fun normalizeSettingsSearchToken(value: String): String {
return value
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
}
@@ -0,0 +1,97 @@
package org.vita3k.emulator.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowInsetsControllerCompat
private val AppBackgroundColor = Color(0xFF111111)
private val AppTopBarColor = Color(0xFF1C1C1C)
private val AppColorScheme = darkColorScheme(
primary = Color(0xFFFF9800),
onPrimary = Color(0xFF1A0D00),
primaryContainer = Color(0xFF3D1F00),
onPrimaryContainer = Color(0xFFFFD8A8),
secondary = Color(0xFF9E9E9E),
onSecondary = Color(0xFF1A1A1A),
secondaryContainer = Color(0xFF2C2C2C),
onSecondaryContainer = Color(0xFFE0E0E0),
tertiary = Color(0xFFBDBDBD),
onTertiary = Color(0xFF1A1A1A),
background = AppBackgroundColor,
onBackground = Color(0xFFE8E8E8),
surface = AppTopBarColor,
onSurface = Color(0xFFE8E8E8),
surfaceVariant = Color(0xFF2A2A2A),
onSurfaceVariant = Color(0xFFB0B0B0),
surfaceContainerHigh = Color(0xFF222222),
surfaceContainerHighest = Color(0xFF2A2A2A),
outline = Color(0xFF444444),
error = Color(0xFFFF6B6B),
onError = Color(0xFF1A0000)
)
@Composable
@Suppress("DEPRECATION")
fun Vita3KTheme(
content: @Composable () -> Unit
) {
val view = LocalView.current
if (!view.isInEditMode) {
val transparentSystemBar = Color.Transparent.toArgb()
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = transparentSystemBar
window.navigationBarColor = transparentSystemBar
WindowInsetsControllerCompat(window, view).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isStatusBarContrastEnforced = false
window.isNavigationBarContrastEnforced = false
}
}
}
MaterialTheme(
colorScheme = AppColorScheme,
content = content
)
}
/**
* A fraction in [0, 1] that is used uniformly for all modal scrim / dialog dim effects.
* Apply it to:
* - ModalBottomSheet via `scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)`
* - Dialog / AlertDialog via [ApplyDialogDim]
*/
const val SCRIM_ALPHA = 0.4f
/**
* Must be called inside a Dialog or AlertDialog composable scope to reduce the system dim amount
* to the app-wide [SCRIM_ALPHA] value. Place it at the beginning of the dialog's content lambda.
*/
@Composable
fun ApplyDialogDim() {
val view = LocalView.current
if (!view.isInEditMode) {
// Check both the view itself and its parent — Compose's Dialog sets DialogWindowProvider
// on the DialogLayout (the direct parent of the composable view tree), but the exact
// position in the hierarchy can vary by API level and Material3 version.
DisposableEffect(Unit) {
val provider = (view as? DialogWindowProvider)
?: (view.parent as? DialogWindowProvider)
provider?.window?.setDimAmount(SCRIM_ALPHA)
onDispose {}
}
}
}
@@ -0,0 +1,39 @@
package org.vita3k.emulator.ui.viewmodel
import androidx.annotation.StringRes
import org.vita3k.emulator.R
enum class AppActionGroup(
@StringRes val labelResId: Int
) {
DELETE(R.string.action_group_delete),
OTHER(R.string.action_group_other)
}
enum class AppAction(
@StringRes val labelResId: Int,
val destructive: Boolean,
val group: AppActionGroup,
val maskBit: Int
) {
DELETE_APPLICATION(R.string.app_action_application, true, AppActionGroup.DELETE, 1 shl 0),
DELETE_SAVE_DATA(R.string.app_action_save_data, true, AppActionGroup.DELETE, 1 shl 1),
DELETE_PATCH(R.string.app_action_patch, true, AppActionGroup.DELETE, 1 shl 2),
DELETE_DLC(R.string.app_action_dlc, true, AppActionGroup.DELETE, 1 shl 3),
DELETE_LICENSE(R.string.app_action_license, true, AppActionGroup.DELETE, 1 shl 4),
DELETE_SHADER_CACHE(R.string.app_action_shader_cache, true, AppActionGroup.DELETE, 1 shl 5),
DELETE_SHADER_LOG(R.string.app_action_shader_log, true, AppActionGroup.DELETE, 1 shl 6),
DELETE_EXPORT_TEXTURES(R.string.app_action_export_textures, true, AppActionGroup.DELETE, 1 shl 7),
DELETE_IMPORT_TEXTURES(R.string.app_action_import_textures, true, AppActionGroup.DELETE, 1 shl 8),
RESET_LAST_PLAYED(R.string.app_action_reset_last_played, false, AppActionGroup.OTHER, 1 shl 9);
companion object {
val all: Set<AppAction> = entries.toSet()
fun fromMask(mask: Int): Set<AppAction> {
return entries.filterTo(linkedSetOf()) { action ->
(mask and action.maskBit) != 0
}
}
}
}
@@ -0,0 +1,344 @@
package org.vita3k.emulator.ui.viewmodel
import android.app.Application
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import org.vita3k.emulator.NativeLib
import org.vita3k.emulator.R
import org.vita3k.emulator.data.FirmwareInstallState
import org.vita3k.emulator.data.AppInfo
import org.vita3k.emulator.data.AppRepository
import org.vita3k.emulator.data.SortOption
import org.vita3k.emulator.data.UpdateCheckResult
import org.vita3k.emulator.data.UpdateCheckStatus
import org.vita3k.emulator.data.ViewMode
class AppsListViewModel(application: Application) : AndroidViewModel(application) {
private fun str(@StringRes id: Int, vararg args: Any): String =
getApplication<Application>().getString(id, *args)
private fun qty(@PluralsRes id: Int, quantity: Int, vararg args: Any): String =
getApplication<Application>().resources.getQuantityString(id, quantity, *args)
private val _allApps = mutableListOf<AppInfo>()
val apps = mutableStateListOf<AppInfo>()
private val _selectedAppIds = mutableStateListOf<String>()
// Stable snapshot of selected IDs avoids .toSet() allocation on every recomposition.
val selectedAppIds: Set<String> by derivedStateOf { _selectedAppIds.toHashSet() }
private val availableActionsByTitleId = mutableStateMapOf<String, Set<AppAction>>()
private var compatSyncStarted = false
private var compatSyncInProgress = false
private var startupUpdateCheckStarted = false
var initialized by mutableStateOf(false)
private set
var loading by mutableStateOf(false)
private set
var firmwareInstallState by mutableStateOf<FirmwareInstallState>(FirmwareInstallState.Missing)
private set
var actionInProgress by mutableStateOf(false)
private set
var actionResultMessage by mutableStateOf<String?>(null)
private set
var selectionMode by mutableStateOf(false)
private set
var searchQuery by mutableStateOf("")
private set
var sortOption by mutableStateOf(SortOption.TITLE)
private set
var viewMode by mutableStateOf(ViewMode.LIST)
private set
var appVersion by mutableStateOf("")
private set
var updateCheckInProgress by mutableStateOf(false)
private set
var updateCheckResult by mutableStateOf<UpdateCheckResult?>(null)
private set
// --- App info dialog ---
var infoDialogApp by mutableStateOf<AppInfo?>(null)
private set
var infoAppInstallSizeBytes by mutableStateOf(-1L)
private set
fun initialize(storagePath: String) {
if (initialized || loading) return
loading = true
viewModelScope.launch {
appVersion = AppRepository.getAppVersion()
val success = AppRepository.initialize(storagePath)
initialized = success
if (success) {
firmwareInstallState = AppRepository.getFirmwareInstallState()
loadApps()
startCompatibilitySync()
startUpdateCheckIfEnabled()
}
loading = false
}
}
fun refreshAppsList(syncCompatibility: Boolean = false) {
if (!initialized) return
viewModelScope.launch {
loading = true
AppRepository.refreshAppsList()
if (syncCompatibility) {
syncCompatibilityDatabase()
}
firmwareInstallState = AppRepository.getFirmwareInstallState()
loadApps()
loading = false
}
}
fun reloadAppsList() {
if (!initialized) return
viewModelScope.launch {
loading = true
firmwareInstallState = AppRepository.getFirmwareInstallState()
loadApps()
loading = false
}
}
fun setSearch(query: String) {
searchQuery = query
applyFilterAndSort()
}
fun setSort(option: SortOption) {
sortOption = option
applyFilterAndSort()
}
fun toggleViewMode() {
viewMode = if (viewMode == ViewMode.LIST) ViewMode.GRID else ViewMode.LIST
}
fun dismissActionResult() {
actionResultMessage = null
}
fun dismissUpdateCheckResult() {
updateCheckResult = null
}
// Returns the actions available for a title, defaulting to emptySet() until the
// async fetch completes (prevents showing all actions as enabled before data arrives).
fun getAvailableActions(titleId: String): Set<AppAction> {
return availableActionsByTitleId[titleId] ?: emptySet()
}
fun prepareAppActions(app: AppInfo) {
if (!initialized) return
viewModelScope.launch {
availableActionsByTitleId[app.titleId] = AppRepository.getAvailableAppActions(app.titleId)
}
}
fun updateSelectionMode(enabled: Boolean) {
selectionMode = enabled
if (!enabled) {
_selectedAppIds.clear()
}
}
fun toggleAppSelection(app: AppInfo) {
if (!selectionMode) selectionMode = true
if (_selectedAppIds.contains(app.titleId)) {
_selectedAppIds.remove(app.titleId)
} else {
_selectedAppIds.add(app.titleId)
}
if (_selectedAppIds.isEmpty()) selectionMode = false
}
fun selectAllVisibleApps() {
if (apps.isEmpty()) {
selectionMode = false
_selectedAppIds.clear()
return
}
selectionMode = true
_selectedAppIds.clear()
_selectedAppIds.addAll(apps.map { it.titleId })
}
fun runBatchDeleteSelected() {
if (!initialized || actionInProgress || _selectedAppIds.isEmpty()) return
runActionWithProgress {
var successCount = 0
val selected = _selectedAppIds.toList()
for (titleId in selected) {
if (AppRepository.runAppAction(titleId, AppAction.DELETE_APPLICATION)) {
successCount++
}
}
refreshAppsList()
_selectedAppIds.clear()
selectionMode = false
if (successCount == selected.size)
qty(R.plurals.batch_delete_success, successCount, successCount)
else
str(R.string.batch_delete_partial, successCount, selected.size)
}
}
fun runAppAction(app: AppInfo, action: AppAction) {
if (!initialized || actionInProgress) return
runActionWithProgress {
val success = AppRepository.runAppAction(app.titleId, action)
if (success) {
if (action.group == AppActionGroup.DELETE) refreshAppsList()
availableActionsByTitleId[app.titleId] = AppRepository.getAvailableAppActions(app.titleId)
str(R.string.action_deleted_success, str(action.labelResId), app.title)
} else {
str(R.string.action_deleted_failed, str(action.labelResId), app.title)
}
}
}
fun showAppInfo(app: AppInfo) {
infoDialogApp = app
infoAppInstallSizeBytes = -1L
viewModelScope.launch {
infoAppInstallSizeBytes = AppRepository.getAppInstallSize(app.titleId)
}
}
fun dismissAppInfo() {
infoDialogApp = null
}
fun checkForUpdates(manual: Boolean = true) {
if (updateCheckInProgress) {
if (manual) {
updateCheckResult = UpdateCheckResult(
status = UpdateCheckStatus.Failed,
message = str(R.string.updates_check_in_progress),
currentDisplayVersion = appVersion
)
}
return
}
updateCheckInProgress = true
viewModelScope.launch {
try {
val result = AppRepository.checkForUpdates(
appVersion = appVersion,
officialBuild = NativeLib.isOfficialBuild()
)
if (manual || result.status == UpdateCheckStatus.UpdateAvailable || result.status == UpdateCheckStatus.CustomBuildCanUpdate) {
updateCheckResult = result
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
if (manual) {
updateCheckResult = UpdateCheckResult(
status = UpdateCheckStatus.Failed,
message = str(R.string.install_error_generic, e.message ?: ""),
currentDisplayVersion = appVersion
)
}
} finally {
updateCheckInProgress = false
}
}
}
private fun runActionWithProgress(block: suspend () -> String) {
actionInProgress = true
viewModelScope.launch {
try {
actionResultMessage = block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
actionResultMessage = str(R.string.install_error_generic, e.message ?: "")
} finally {
actionInProgress = false
}
}
}
private suspend fun loadApps() {
val list = AppRepository.getAppList()
_allApps.clear()
_allApps.addAll(list)
applyFilterAndSort()
}
private fun startCompatibilitySync() {
if (compatSyncStarted) return
compatSyncStarted = true
viewModelScope.launch {
if (syncCompatibilityDatabase()) {
loadApps()
}
}
}
private fun startUpdateCheckIfEnabled() {
if (startupUpdateCheckStarted) return
startupUpdateCheckStarted = true
viewModelScope.launch {
val enabled = runCatching { NativeLib.getGlobalConfig().checkForUpdatesMode != 0 }.getOrDefault(true)
if (enabled) {
checkForUpdates(manual = false)
}
}
}
private suspend fun syncCompatibilityDatabase(): Boolean {
if (compatSyncInProgress) return false
compatSyncInProgress = true
return try {
AppRepository.syncCompatibilityDatabase()
} finally {
compatSyncInProgress = false
}
}
private fun applyFilterAndSort() {
val filtered = if (searchQuery.isBlank()) {
_allApps
} else {
_allApps.filter {
it.title.contains(searchQuery, ignoreCase = true) ||
it.titleId.contains(searchQuery, ignoreCase = true)
}
}
val sorted = when (sortOption) {
SortOption.TITLE -> filtered.sortedBy { it.title.lowercase() }
SortOption.LAST_PLAYED -> filtered.sortedByDescending { it.lastPlayed }
SortOption.COMPATIBILITY -> filtered.sortedByDescending { it.compatibility.value }
}
apps.clear()
apps.addAll(sorted)
}
}
@@ -0,0 +1,315 @@
package org.vita3k.emulator.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import org.vita3k.emulator.Emulator
import org.vita3k.emulator.NativeLib
import org.vita3k.emulator.data.NativeImeState
import org.vita3k.emulator.overlay.DEFAULT_OVERLAY_MASK
import org.vita3k.emulator.overlay.OverlayConfig
import org.vita3k.emulator.overlay.OverlayConfigStore
data class EmulationSessionUiState(
val titleId: String = "",
val gameTitle: String = "",
val showMenu: Boolean = false,
val isPaused: Boolean = false,
val isEditingControls: Boolean = false,
val showExitConfirmation: Boolean = false,
val controlsVisible: Boolean = true,
val overlayMask: Int = DEFAULT_OVERLAY_MASK,
val l2r2Visible: Boolean = false,
val l3r3Visible: Boolean = false,
val touchSwitchVisible: Boolean = true,
val hideToggleVisible: Boolean = false,
val overlayScale: Int = 100,
val overlayOpacity: Int = 100,
val hideOverlayWhenControllerConnected: Boolean = true,
val controllerConnected: Boolean = false,
val statusMessage: String? = null
)
class EmulationSessionViewModel(application: Application) : AndroidViewModel(application) {
private var initialized = false
var uiState by mutableStateOf(EmulationSessionUiState())
private set
var imeState by mutableStateOf<NativeImeState?>(null)
private set
fun initialize(titleId: String?, gameTitle: String?) {
if (initialized && uiState.titleId == titleId.orEmpty()) {
return
}
val overlayConfig = OverlayConfigStore.load(getApplication())
uiState = uiState.withOverlayConfig(overlayConfig).copy(
titleId = titleId.orEmpty(),
gameTitle = gameTitle.orEmpty()
)
initialized = true
}
fun updateRunningApp(titleId: String?, gameTitle: String?) {
uiState = uiState.copy(
titleId = titleId.orEmpty(),
gameTitle = gameTitle.orEmpty()
)
initialized = true
}
fun applyOverlayState(emulator: Emulator) {
emulator.setControllerOverlayScale(uiState.overlayScale / 100f)
emulator.setControllerOverlayOpacity(uiState.overlayOpacity)
val visibleMask = when {
uiState.showMenu -> 0
uiState.isEditingControls -> activeOverlayMask()
uiState.hideOverlayWhenControllerConnected && uiState.controllerConnected -> 0
else -> uiState.overlayMask
}
emulator.setControllerOverlayState(visibleMask, uiState.isEditingControls, false)
}
fun handleBackPressed(emulator: Emulator): Boolean {
return when {
uiState.isEditingControls -> {
finishControlsEditor(emulator)
true
}
uiState.showMenu -> {
closeMenu(emulator)
true
}
else -> {
openMenu(emulator, pauseGame = false)
true
}
}
}
@JvmOverloads
fun openMenu(emulator: Emulator, pauseGame: Boolean = true) {
if (uiState.showMenu) {
return
}
emulator.prepareImeForPauseMenu()
emulator.ensurePauseMenuOnTop()
releaseInputs(emulator)
val paused = if (pauseGame) {
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
return
}
true
} else {
isNativeAppPaused()
}
if (!setNativeInputIntercepted(true)) {
if (pauseGame) {
setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, false)
}
return
}
uiState = uiState.copy(
showMenu = true,
isPaused = paused,
isEditingControls = false,
showExitConfirmation = false,
statusMessage = null
)
emulator.setControllerOverlayState(0, false, false)
}
fun closeMenu(emulator: Emulator) {
releaseInputs(emulator)
if (uiState.isPaused) {
setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, false)
}
setNativeInputIntercepted(false)
uiState = uiState.copy(
showMenu = false,
isPaused = false,
isEditingControls = false,
showExitConfirmation = false,
statusMessage = null
)
applyOverlayState(emulator)
emulator.restoreImeAfterPauseMenu()
}
fun togglePause(emulator: Emulator) {
if (uiState.isPaused) {
closeMenu(emulator)
} else {
releaseInputs(emulator)
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
return
}
uiState = uiState.copy(isPaused = true, statusMessage = null)
emulator.setControllerOverlayState(0, false, false)
}
}
fun requestExit() {
uiState = uiState.copy(showExitConfirmation = true)
}
fun dismissExitConfirmation() {
uiState = uiState.copy(showExitConfirmation = false)
}
fun confirmExit(emulator: Emulator) {
releaseInputs(emulator)
uiState = uiState.copy(showExitConfirmation = false, statusMessage = null)
emulator.requestNativeQuit()
}
fun setControlsVisible(emulator: Emulator, visible: Boolean) {
updateOverlayConfig(emulator) { it.withControlsVisible(visible) }
}
fun setL2R2Visible(emulator: Emulator, visible: Boolean) {
updateOverlayConfig(emulator) { it.withL2R2Visible(visible) }
}
fun setL3R3Visible(emulator: Emulator, visible: Boolean) {
updateOverlayConfig(emulator) { it.withL3R3Visible(visible) }
}
fun setTouchSwitchVisible(emulator: Emulator, visible: Boolean) {
updateOverlayConfig(emulator) { it.withTouchSwitchVisible(visible) }
}
fun setHideToggleVisible(emulator: Emulator, visible: Boolean) {
updateOverlayConfig(emulator) { it.withHideToggleVisible(visible) }
}
fun setOverlayScale(emulator: Emulator, value: Int) {
updateOverlayConfig(emulator) { it.copy(overlayScale = value) }
}
fun setOverlayOpacity(emulator: Emulator, value: Int) {
updateOverlayConfig(emulator) { it.copy(overlayOpacity = value) }
}
fun setControllerConnected(emulator: Emulator, connected: Boolean) {
if (uiState.controllerConnected == connected) {
return
}
uiState = uiState.copy(controllerConnected = connected)
applyOverlayState(emulator)
}
fun startControlsEditor(emulator: Emulator) {
val mask = activeOverlayMask()
releaseInputs(emulator)
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
return
}
persistOverlayConfig(currentOverlayConfig().copy(overlayMask = mask))
uiState = uiState.copy(
showMenu = false,
isPaused = true,
isEditingControls = true,
controlsVisible = true,
overlayMask = mask,
showExitConfirmation = false,
statusMessage = null
)
emulator.setControllerOverlayState(mask, true, false)
}
fun finishControlsEditor(emulator: Emulator) {
releaseInputs(emulator)
uiState = uiState.copy(
showMenu = true,
isPaused = true,
isEditingControls = false,
statusMessage = null
)
emulator.ensurePauseMenuOnTop()
emulator.setControllerOverlayState(0, false, false)
}
fun resetControlsLayout(emulator: Emulator) {
emulator.setControllerOverlayState(activeOverlayMask(), uiState.isEditingControls, true)
uiState = uiState.copy(statusMessage = null)
}
fun clearStatusMessage() {
uiState = uiState.copy(statusMessage = null)
}
fun showStatusMessage(message: String) {
uiState = uiState.copy(statusMessage = message)
}
fun updateImeState(state: NativeImeState?) {
if (imeState == state) {
return
}
imeState = state
}
fun currentOverlayConfig(): OverlayConfig {
return OverlayConfig(
overlayMask = uiState.overlayMask,
overlayScale = uiState.overlayScale,
overlayOpacity = uiState.overlayOpacity,
hideOverlayWhenControllerConnected = uiState.hideOverlayWhenControllerConnected
).normalized()
}
fun updateOverlayConfig(emulator: Emulator, transform: (OverlayConfig) -> OverlayConfig) {
val updatedConfig = transform(currentOverlayConfig()).normalized()
persistOverlayConfig(updatedConfig)
uiState = uiState.withOverlayConfig(updatedConfig)
applyOverlayState(emulator)
}
private fun activeOverlayMask(): Int {
return if (uiState.overlayMask == 0) DEFAULT_OVERLAY_MASK else uiState.overlayMask
}
private fun persistOverlayConfig(config: OverlayConfig) {
OverlayConfigStore.save(getApplication(), config)
}
private fun releaseInputs(emulator: Emulator) {
runCatching { emulator.releaseControllerOverlayInputs() }
}
private fun setNativePauseReasonEnabled(reasonMask: Int, enabled: Boolean): Boolean {
return runCatching { NativeLib.setPauseReasonEnabled(reasonMask, enabled) }.getOrDefault(false)
}
private fun setNativeInputIntercepted(intercepted: Boolean): Boolean {
return runCatching { NativeLib.setInputIntercepted(intercepted) }.getOrDefault(false)
}
private fun isNativeAppPaused(): Boolean {
return runCatching { NativeLib.isAppPaused() }.getOrDefault(false)
}
private fun EmulationSessionUiState.withOverlayConfig(config: OverlayConfig): EmulationSessionUiState {
val normalized = config.normalized()
return copy(
controlsVisible = normalized.controlsVisible,
overlayMask = normalized.overlayMask,
l2r2Visible = normalized.l2r2Visible,
l3r3Visible = normalized.l3r3Visible,
touchSwitchVisible = normalized.touchSwitchVisible,
hideToggleVisible = normalized.hideToggleVisible,
overlayScale = normalized.overlayScale,
overlayOpacity = normalized.overlayOpacity,
hideOverlayWhenControllerConnected = normalized.hideOverlayWhenControllerConnected
)
}
}
@@ -0,0 +1,247 @@
package org.vita3k.emulator.ui.viewmodel
import android.app.Application
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.vita3k.emulator.InstallServiceController
import org.vita3k.emulator.R
import org.vita3k.emulator.data.InstallRepository
import java.io.File
enum class InstallType {
FIRMWARE, PKG, ARCHIVE, LICENSE
}
enum class InstallResultStatus {
SUCCESS, PARTIAL, ERROR
}
data class DeleteSourceOption(
val label: String,
val paths: List<String>
)
data class InstallResult(
val status: InstallResultStatus,
val message: String,
val deleteOptions: List<DeleteSourceOption> = emptyList()
)
class InstallViewModel(application: Application) : AndroidViewModel(application) {
private fun str(@StringRes id: Int, vararg args: Any): String =
getApplication<Application>().getString(id, *args)
var showInstallSheet by mutableStateOf(false)
private set
var installing by mutableStateOf(false)
private set
var progress by mutableStateOf(0)
private set
var statusMessage by mutableStateOf("")
private set
var installResult by mutableStateOf<InstallResult?>(null)
private set
var showPkgLicenseDialog by mutableStateOf(false)
private set
var completedInstallToken by mutableStateOf(0L)
private set
private var pendingPkgPath: String? = null
private var pendingPkgLicensePath: String? = null
private var lastObservedCompletionId = 0L
init {
viewModelScope.launch {
InstallServiceController.state.collectLatest { state ->
if (state.operationId == 0L) {
return@collectLatest
}
installing = state.installing
progress = state.progress
statusMessage = state.statusMessage
installResult = state.installResult
if (!state.installing && state.installResult != null && state.operationId != lastObservedCompletionId) {
lastObservedCompletionId = state.operationId
completedInstallToken = state.operationId
}
}
}
}
fun showSheet() {
showInstallSheet = true
}
fun hideSheet() {
showInstallSheet = false
}
fun confirmInstallResult(selectedDeleteOptions: List<DeleteSourceOption>) {
val paths = selectedDeleteOptions
.flatMap(DeleteSourceOption::paths)
.filter(String::isNotBlank)
.distinct()
installResult = null
InstallServiceController.clearResult()
if (paths.isEmpty()) {
return
}
viewModelScope.launch {
paths.forEach { path ->
runCatching { File(path).delete() }
}
}
}
fun installFirmware(path: String) {
InstallServiceController.clearResult()
InstallServiceController.startFirmware(getApplication(), path)
}
fun onPkgPicked(path: String) {
InstallServiceController.clearResult()
pendingPkgLicensePath = null
beginInstall(R.string.install_status_preparing)
viewModelScope.launch {
try {
pendingPkgPath = path
val autoZrif = InstallRepository.findPkgZrif(path)
installing = false
if (autoZrif.isNotEmpty()) {
confirmPkgInstall(autoZrif)
} else {
showPkgLicenseDialog = true
}
} catch (e: Exception) {
pendingPkgPath = null
pendingPkgLicensePath = null
installing = false
installResult = InstallResult(
status = InstallResultStatus.ERROR,
message = str(R.string.install_error_generic, e.message ?: ""),
deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_package_file, path)
)
)
}
}
}
fun onPkgLicenseFilePicked(path: String) {
pendingPkgLicensePath = path
viewModelScope.launch {
try {
val zrif = InstallRepository.convertRifToZrif(path)
if (zrif.isEmpty()) {
installResult = InstallResult(
status = InstallResultStatus.ERROR,
message = str(R.string.install_error_read_license),
deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_package_file, pendingPkgPath),
deleteOption(R.string.install_delete_license_file, path)
)
)
cancelPkgInstall()
return@launch
}
confirmPkgInstall(zrif)
} catch (e: Exception) {
installResult = InstallResult(
status = InstallResultStatus.ERROR,
message = str(R.string.install_error_generic, e.message ?: ""),
deleteOptions = sourceDeleteOptions(
deleteOption(R.string.install_delete_package_file, pendingPkgPath),
deleteOption(R.string.install_delete_license_file, path)
)
)
cancelPkgInstall()
}
}
}
fun confirmPkgInstall(zrif: String) {
showPkgLicenseDialog = false
val path = pendingPkgPath ?: run {
installResult = InstallResult(
status = InstallResultStatus.ERROR,
message = str(R.string.install_error_pkg_path_lost)
)
pendingPkgLicensePath = null
return
}
val licensePath = pendingPkgLicensePath
pendingPkgPath = null
pendingPkgLicensePath = null
InstallServiceController.clearResult()
InstallServiceController.startPkg(getApplication(), path, zrif, licensePath)
}
fun cancelPkgInstall() {
showPkgLicenseDialog = false
pendingPkgPath = null
pendingPkgLicensePath = null
}
fun installArchive(path: String) {
InstallServiceController.clearResult()
InstallServiceController.startArchive(getApplication(), path)
}
fun installArchiveFolder(paths: List<String>) {
InstallServiceController.clearResult()
InstallServiceController.startArchiveFolder(getApplication(), paths)
}
fun installLicense(path: String) {
InstallServiceController.clearResult()
InstallServiceController.startLicenseFile(getApplication(), path)
}
fun installLicenseFromZrif(zrif: String) {
InstallServiceController.clearResult()
InstallServiceController.startLicenseZrif(getApplication(), zrif)
}
private fun beginInstall(@StringRes statusResId: Int) {
installResult = null
installing = true
progress = 0
statusMessage = str(statusResId)
}
private fun deleteOption(@StringRes labelResId: Int, path: String?): DeleteSourceOption? =
deleteOption(labelResId, listOfNotNull(path))
private fun deleteOption(
@StringRes labelResId: Int,
paths: List<String>
): DeleteSourceOption? {
val normalized = paths
.filter(String::isNotBlank)
.distinct()
if (normalized.isEmpty()) {
return null
}
return DeleteSourceOption(
label = str(labelResId),
paths = normalized
)
}
private fun sourceDeleteOptions(vararg options: DeleteSourceOption?): List<DeleteSourceOption> =
options.filterNotNull()
}
@@ -0,0 +1,617 @@
package org.vita3k.emulator.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.vita3k.emulator.NativeLib
import org.vita3k.emulator.R
import org.vita3k.emulator.data.AppStorage
import org.vita3k.emulator.data.CustomDriverLoadStatus
import org.vita3k.emulator.data.CustomDriverSupportInfo
import org.vita3k.emulator.data.EmulatorConfig
import org.vita3k.emulator.data.RestartRequiredSetting
import org.vita3k.emulator.data.SettingsRepository
import org.vita3k.emulator.data.SettingsSnapshot
import org.vita3k.emulator.data.UiLanguages
data class SettingsOperationResult(
val message: String,
val isError: Boolean
)
private const val GLOBAL_SETTINGS_ROUTE_KEY = "__global__"
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private fun initialConfig(): EmulatorConfig =
if (NativeLib.isInitialized()) NativeLib.getDefaultConfig() else EmulatorConfig()
private fun str(resId: Int, vararg args: Any): String =
getApplication<Application>().getString(resId, *args)
private var loadJob: Job? = null
private var memoryMappingRefreshJob: Job? = null
private var loadRequestId = 0
private var memoryMappingRefreshRequestId = 0
private var activeLoadRouteKey: String? = null
private var loadedRouteKey by mutableStateOf<String?>(null)
var config by mutableStateOf(initialConfig())
private set
var currentStoragePath by mutableStateOf(
if (NativeLib.isInitialized()) NativeLib.getCurrentEmulatorPath() else AppStorage.defaultStoragePath(getApplication())
)
private set
val defaultStoragePath: String = AppStorage.defaultStoragePath(getApplication())
private var originalConfig by mutableStateOf(config.copy())
private var originalModulesList: List<Pair<String, Boolean>> = emptyList()
var titleId: String? = null
private set
var loading by mutableStateOf(false)
private set
var saving by mutableStateOf(false)
private set
var hasCustomConfig by mutableStateOf(false)
private set
var installedCustomDrivers by mutableStateOf<List<String>>(emptyList())
private set
var showCustomDriverOptions by mutableStateOf(true)
private set
var customDriverBusy by mutableStateOf(false)
private set
var operationResult by mutableStateOf<SettingsOperationResult?>(null)
private set
var supportedMemoryMappingMask by mutableStateOf(1)
private set
var customDriverLoadStatus by mutableStateOf(CustomDriverLoadStatus.Default)
private set
var availableCameras by mutableStateOf<List<String>>(emptyList())
private set
var availableAdhocAddresses by mutableStateOf<List<Pair<String, String>>>(emptyList())
private set
val isDirty: Boolean
get() = config != originalConfig
var modulesList by mutableStateOf<List<Pair<String, Boolean>>>(emptyList())
private set
var modulesSearch by mutableStateOf("")
private set
fun isLoaded(titleId: String?): Boolean = loadedRouteKey == routeKey(titleId)
fun preloadGlobalSettings() {
load(titleId = null)
}
fun load(titleId: String?, force: Boolean = false) {
val routeKey = routeKey(titleId)
this.titleId = titleId
if (!force && loading && activeLoadRouteKey == routeKey) {
return
}
loadJob?.cancel()
val requestId = ++loadRequestId
activeLoadRouteKey = routeKey
loading = true
loadJob = viewModelScope.launch {
try {
val snapshot = withContext(Dispatchers.IO) {
SettingsRepository.load(titleId)
}
if (loadRequestId != requestId) return@launch
applySnapshot(titleId, routeKey, snapshot)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
if (loadRequestId == requestId) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
}
} finally {
if (loadRequestId == requestId) {
loading = false
activeLoadRouteKey = null
loadJob = null
}
}
}
}
fun save(forceCustomConfig: Boolean = false, onSaved: ((List<RestartRequiredSetting>) -> Unit)? = null) {
if (saving) return
val configSnapshot = config.copy()
val routeTitleId = titleId
val saveTitleId = routeTitleId?.takeIf { forceCustomConfig || hasCustomConfig }
saving = true
viewModelScope.launch {
var saveSucceeded = false
var restartRequiredSettings: List<RestartRequiredSetting> = emptyList()
try {
val saveResult = withContext(Dispatchers.IO) {
SettingsRepository.save(routeTitleId, saveTitleId, configSnapshot)
}
hasCustomConfig = saveResult.hasCustomConfig
restartRequiredSettings = saveResult.restartRequiredSettings
originalConfig = configSnapshot.copy()
originalModulesList = modulesList
loadedRouteKey = routeKey(routeTitleId)
saveSucceeded = true
if (saveTitleId == null) {
UiLanguages.applyAndPersist(getApplication(), configSnapshot.userLang)
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
saving = false
}
if (saveSucceeded) {
onSaved?.invoke(restartRequiredSettings)
}
}
}
fun changeStorageFolder(storagePath: String, onStorageChanged: () -> Unit) {
viewModelScope.launch {
try {
if (storagePath.isNullOrBlank()) {
operationResult = SettingsOperationResult(
str(R.string.settings_emulator_storage_folder_resolve_failed),
true
)
return@launch
}
val success = withContext(Dispatchers.IO) {
SettingsRepository.setCurrentEmulatorPath(storagePath)
}
if (!success) {
operationResult = SettingsOperationResult(
str(R.string.settings_emulator_storage_folder_change_failed),
true
)
return@launch
}
val routeKey = routeKey(titleId)
val snapshot = withContext(Dispatchers.IO) {
SettingsRepository.load(titleId)
}
applySnapshot(titleId, routeKey, snapshot)
onStorageChanged()
operationResult = SettingsOperationResult(
str(R.string.settings_emulator_storage_folder_change_success, storagePath),
false
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
}
}
}
fun resetStorageFolder(onStorageChanged: () -> Unit) {
changeStorageFolder(defaultStoragePath, onStorageChanged)
}
fun resetToDefaults() {
viewModelScope.launch {
try {
val defaults = withContext(Dispatchers.IO) {
SettingsRepository.loadDefaults()
}
config = defaults.config.copy()
modulesList = defaults.modulesList
installedCustomDrivers = defaults.installedCustomDrivers
supportedMemoryMappingMask = defaults.supportedMemoryMappingMask
customDriverLoadStatus = defaults.customDriverLoadStatus
availableCameras = defaults.availableCameras
availableAdhocAddresses = defaults.availableAdhocAddresses
operationResult = SettingsOperationResult(
str(R.string.settings_reset_defaults_done),
false
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
}
}
}
fun deleteCustomConfig(onDeleted: ((List<RestartRequiredSetting>) -> Unit)? = null) {
val id = titleId ?: return
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
SettingsRepository.deleteCustomConfig(id)
}
applySnapshot(id, routeKey(id), result.snapshot)
onDeleted?.invoke(result.restartRequiredSettings)
}
}
fun discardChanges() {
config = originalConfig.copy()
modulesList = originalModulesList
modulesSearch = ""
}
fun clearAllCustomConfigs() {
viewModelScope.launch {
val deletedCount = withContext(Dispatchers.IO) {
SettingsRepository.clearAllCustomConfigs()
}
operationResult = SettingsOperationResult(
str(R.string.settings_clear_all_custom_configs_success, deletedCount),
false
)
}
}
fun installCustomDriver(path: String) {
if (customDriverBusy) return
customDriverBusy = true
viewModelScope.launch {
try {
if (path.isBlank()) {
operationResult = SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_resolve_failed),
true
)
return@launch
}
val installedName = withContext(Dispatchers.IO) {
SettingsRepository.installCustomDriver(path)
}
if (installedName.isEmpty()) {
operationResult = SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_install_failed),
true
)
return@launch
}
installedCustomDrivers = withContext(Dispatchers.IO) {
SettingsRepository.getInstalledCustomDrivers()
}
val supportInfo = withContext(Dispatchers.IO) {
SettingsRepository.getCustomDriverSupportInfo(installedName)
}
applyCustomDriverSupportInfo(
requestedDriverName = installedName,
supportInfo = supportInfo,
coerceSelectionOnFallback = false
)
operationResult = SettingsOperationResult(
if (supportInfo.loadStatus == CustomDriverLoadStatus.Loaded) {
str(R.string.settings_gpu_custom_driver_install_success, installedName)
} else {
str(R.string.settings_gpu_custom_driver_install_fallback, installedName)
},
supportInfo.loadStatus != CustomDriverLoadStatus.Loaded
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
customDriverBusy = false
}
}
}
fun removeCustomDriver(driverName: String) {
if (customDriverBusy || driverName.isEmpty()) return
customDriverBusy = true
viewModelScope.launch {
try {
val removed = withContext(Dispatchers.IO) {
SettingsRepository.removeCustomDriver(driverName)
}
if (!removed) {
operationResult = SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_remove_failed, driverName),
true
)
return@launch
}
installedCustomDrivers = withContext(Dispatchers.IO) {
SettingsRepository.getInstalledCustomDrivers()
}
if (config.customDriverName == driverName) {
val supportInfo = withContext(Dispatchers.IO) {
SettingsRepository.getCustomDriverSupportInfo("")
}
applyCustomDriverSupportInfo(
requestedDriverName = "",
supportInfo = supportInfo,
coerceSelectionOnFallback = false
)
}
operationResult = SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_remove_success, driverName),
false
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
customDriverBusy = false
}
}
}
fun setCameraImage(isFront: Boolean, path: String) {
viewModelScope.launch {
try {
if (path.isNullOrBlank()) {
operationResult = SettingsOperationResult(
str(R.string.settings_camera_image_resolve_failed),
true
)
return@launch
}
update {
if (isFront) {
frontCameraType = 1
frontCameraImage = path
} else {
backCameraType = 1
backCameraImage = path
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
}
}
}
fun dismissOperationResult() {
operationResult = null
}
fun selectCustomDriver(driverName: String) {
if (customDriverBusy) return
val normalizedDriverName = driverName.trim()
if (config.customDriverName == normalizedDriverName) {
refreshSupportedMemoryMappingMask(normalizedDriverName)
return
}
customDriverBusy = true
config = config.copy().apply {
customDriverName = normalizedDriverName
}
viewModelScope.launch {
try {
val supportInfo = withContext(Dispatchers.IO) {
SettingsRepository.getCustomDriverSupportInfo(normalizedDriverName)
}
applyCustomDriverSupportInfo(
requestedDriverName = normalizedDriverName,
supportInfo = supportInfo,
coerceSelectionOnFallback = false
)
operationResult = when {
normalizedDriverName.isEmpty() -> null
supportInfo.loadStatus == CustomDriverLoadStatus.Loaded -> SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_selected_success, normalizedDriverName),
false
)
else -> SettingsOperationResult(
str(R.string.settings_gpu_custom_driver_selected_fallback, normalizedDriverName),
true
)
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = SettingsOperationResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
val fallbackSupportInfo = CustomDriverSupportInfo(
supportedMemoryMappingMask = 1,
loadStatus = if (normalizedDriverName.isEmpty()) {
CustomDriverLoadStatus.Default
} else {
CustomDriverLoadStatus.Fallback
}
)
applyCustomDriverSupportInfo(
requestedDriverName = normalizedDriverName,
supportInfo = fallbackSupportInfo,
coerceSelectionOnFallback = false
)
} finally {
customDriverBusy = false
}
}
}
fun update(block: EmulatorConfig.() -> Unit) {
val previousDriverName = config.customDriverName
val updatedConfig = config.copy().apply(block)
config = updatedConfig
if (previousDriverName != updatedConfig.customDriverName) {
refreshSupportedMemoryMappingMask(updatedConfig.customDriverName)
}
}
fun onModulesSearchChange(query: String) {
modulesSearch = query
}
fun toggleModule(name: String) {
val newList = modulesList.map { (n, e) -> if (n == name) n to !e else n to e }
modulesList = newList
update { lleModules = newList.filter { it.second }.map { it.first }.toTypedArray() }
}
private fun refreshSupportedMemoryMappingMask(
customDriverName: String,
coerceSelectionOnFallback: Boolean = false,
onApplied: ((CustomDriverSupportInfo) -> Unit)? = null
) {
memoryMappingRefreshJob?.cancel()
val requestId = ++memoryMappingRefreshRequestId
memoryMappingRefreshJob = viewModelScope.launch {
try {
val supportInfo = withContext(Dispatchers.IO) {
SettingsRepository.getCustomDriverSupportInfo(customDriverName)
}
if (memoryMappingRefreshRequestId != requestId) return@launch
if (!coerceSelectionOnFallback && config.customDriverName != customDriverName) return@launch
applyCustomDriverSupportInfo(customDriverName, supportInfo, coerceSelectionOnFallback)
onApplied?.invoke(supportInfo)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
if (memoryMappingRefreshRequestId != requestId) return@launch
if (!coerceSelectionOnFallback && config.customDriverName != customDriverName) return@launch
val fallbackSupportInfo = CustomDriverSupportInfo(
supportedMemoryMappingMask = 1,
loadStatus = if (customDriverName.isEmpty()) {
CustomDriverLoadStatus.Default
} else {
CustomDriverLoadStatus.Fallback
}
)
applyCustomDriverSupportInfo(customDriverName, fallbackSupportInfo, coerceSelectionOnFallback)
onApplied?.invoke(fallbackSupportInfo)
}
}
}
private fun applyCustomDriverSupportInfo(
requestedDriverName: String,
supportInfo: CustomDriverSupportInfo,
coerceSelectionOnFallback: Boolean
) {
val shouldFallbackToDefault = requestedDriverName.isNotEmpty() &&
supportInfo.loadStatus == CustomDriverLoadStatus.Fallback &&
coerceSelectionOnFallback
val effectiveDriverName = if (shouldFallbackToDefault) "" else requestedDriverName
val effectiveLoadStatus = when {
effectiveDriverName.isEmpty() -> CustomDriverLoadStatus.Default
else -> supportInfo.loadStatus
}
supportedMemoryMappingMask = supportInfo.supportedMemoryMappingMask
customDriverLoadStatus = effectiveLoadStatus
val coercedMapping = coerceMemoryMapping(config.memoryMapping, supportInfo.supportedMemoryMappingMask)
val currentConfig = config
if (currentConfig.customDriverName != effectiveDriverName || currentConfig.memoryMapping != coercedMapping) {
config = currentConfig.copy().apply {
customDriverName = effectiveDriverName
memoryMapping = coercedMapping
}
}
}
private fun coerceMemoryMapping(memoryMapping: String, supportedMask: Int): String {
if (isMemoryMappingSupported(memoryMapping, supportedMask)) {
return memoryMapping
}
return when {
(supportedMask and (1 shl 1)) != 0 -> "double-buffer"
(supportedMask and (1 shl 2)) != 0 -> "external-host"
(supportedMask and (1 shl 3)) != 0 -> "page-table"
(supportedMask and (1 shl 4)) != 0 -> "native-buffer"
else -> "disabled"
}
}
private fun isMemoryMappingSupported(memoryMapping: String, supportedMask: Int): Boolean {
val bit = when (memoryMapping) {
"disabled" -> 0
"double-buffer" -> 1
"external-host" -> 2
"page-table" -> 3
"native-buffer" -> 4
else -> return false
}
return (supportedMask and (1 shl bit)) != 0
}
private fun routeKey(titleId: String?): String = titleId ?: GLOBAL_SETTINGS_ROUTE_KEY
private fun applySnapshot(titleId: String?, routeKey: String, snapshot: SettingsSnapshot) {
this.titleId = titleId
currentStoragePath = snapshot.emulatorStoragePath
hasCustomConfig = snapshot.hasCustomConfig
originalConfig = snapshot.config.copy()
originalModulesList = snapshot.modulesList
config = snapshot.config.copy()
modulesList = snapshot.modulesList
installedCustomDrivers = snapshot.installedCustomDrivers
showCustomDriverOptions = snapshot.showCustomDriverOptions
supportedMemoryMappingMask = snapshot.supportedMemoryMappingMask
customDriverLoadStatus = snapshot.customDriverLoadStatus
availableCameras = snapshot.availableCameras
availableAdhocAddresses = snapshot.availableAdhocAddresses
loadedRouteKey = routeKey
}
}
@@ -0,0 +1,163 @@
package org.vita3k.emulator.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import org.vita3k.emulator.R
import org.vita3k.emulator.data.NativeUser
import org.vita3k.emulator.data.UserRepository
data class UserManagementResult(
val message: String,
val isError: Boolean
)
class UserManagementViewModel(application: Application) : AndroidViewModel(application) {
private fun str(resId: Int, vararg args: Any): String =
getApplication<Application>().getString(resId, *args)
var users by mutableStateOf<List<NativeUser>>(emptyList())
private set
var loading by mutableStateOf(false)
private set
var busy by mutableStateOf(false)
private set
var operationResult by mutableStateOf<UserManagementResult?>(null)
private set
fun load() {
if (loading) return
loading = true
viewModelScope.launch {
try {
users = UserRepository.getUsers()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = UserManagementResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
loading = false
}
}
}
fun createUser(name: String, onUsersChanged: () -> Unit = {}) {
val trimmedName = name.trim()
if (busy || trimmedName.isEmpty()) return
busy = true
viewModelScope.launch {
try {
val userId = UserRepository.createUser(trimmedName)
if (userId.isEmpty()) {
operationResult = UserManagementResult(
str(R.string.user_management_create_failed),
true
)
return@launch
}
users = UserRepository.getUsers()
operationResult = UserManagementResult(
str(R.string.user_management_created_success, trimmedName),
false
)
onUsersChanged()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = UserManagementResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
busy = false
}
}
}
fun activateUser(user: NativeUser, onUsersChanged: () -> Unit = {}) {
if (busy || user.active) return
busy = true
viewModelScope.launch {
try {
val success = UserRepository.activateUser(user.id)
if (!success) {
operationResult = UserManagementResult(
str(R.string.user_management_select_failed),
true
)
return@launch
}
users = UserRepository.getUsers()
operationResult = UserManagementResult(
str(R.string.user_management_selected_success, user.name),
false
)
onUsersChanged()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = UserManagementResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
busy = false
}
}
}
fun deleteUser(user: NativeUser, onUsersChanged: () -> Unit = {}) {
if (busy) return
busy = true
viewModelScope.launch {
try {
val success = UserRepository.deleteUser(user.id)
if (!success) {
operationResult = UserManagementResult(
str(R.string.user_management_delete_failed),
true
)
return@launch
}
users = UserRepository.getUsers()
operationResult = UserManagementResult(
str(R.string.user_management_deleted_success, user.name),
false
)
onUsersChanged()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
operationResult = UserManagementResult(
str(R.string.install_error_generic, e.message ?: ""),
true
)
} finally {
busy = false
}
}
}
fun dismissOperationResult() {
operationResult = null
}
}

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show More