From 39e3f8b7c008d77948a445ad98171a5ec07b47d9 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 8 Apr 2026 20:34:37 -0400 Subject: [PATCH] Initial commit: multi-platform emulator with PS1 support Jetpack Compose app with adaptive layouts (phone/tablet/foldable) using Window Size Classes. Integrates a C++ libretro frontend via JNI for actual PS1 emulation using pcsx_rearmed, with AAudio output and ANativeWindow rendering. - Emulation core architecture with interface for NES, SNES, N64, Genesis, PS1, PS2 (PS1 implemented, others placeholder) - Touch controller overlay with D-pad (diagonal support), face buttons, shoulders, and analog sticks - Bluetooth/USB controller detection and input mapping - Customizable controller layout editor with drag-to-reposition - Game library with auto-scan of /sdcard/Games/ subfolders - Auto-import of libretro core .so from /sdcard/Games/cores/ - Save states and SRAM persistence --- .gitignore | 15 + app/.gitignore | 1 + app/build.gradle.kts | 87 +++ app/proguard-rules.pro | 21 + .../lazy/emulate/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 40 ++ app/src/main/cpp/CMakeLists.txt | 22 + app/src/main/cpp/audio_engine.cpp | 114 ++++ app/src/main/cpp/audio_engine.h | 39 ++ app/src/main/cpp/jni_bridge.cpp | 119 ++++ app/src/main/cpp/libretro.h | 245 ++++++++ app/src/main/cpp/libretro_frontend.cpp | 556 ++++++++++++++++++ app/src/main/cpp/libretro_frontend.h | 113 ++++ .../java/com/lazy/emulate/MainActivity.kt | 89 +++ .../java/com/lazy/emulate/data/model/Game.kt | 14 + .../emulate/data/repository/GameRepository.kt | 119 ++++ .../com/lazy/emulate/emulation/ConsoleType.kt | 46 ++ .../lazy/emulate/emulation/EmulationEngine.kt | 36 ++ .../lazy/emulate/emulation/EmulatorCore.kt | 39 ++ .../lazy/emulate/emulation/NativeLibretro.kt | 28 + .../emulation/cores/PlaceholderCore.kt | 43 ++ .../lazy/emulate/emulation/cores/Ps1Core.kt | 196 ++++++ .../lazy/emulate/input/ControllerLayout.kt | 113 ++++ .../lazy/emulate/input/ControllerManager.kt | 148 +++++ .../com/lazy/emulate/input/GamepadButton.kt | 78 +++ .../emulate/ui/adaptive/AdaptiveLayout.kt | 25 + .../emulate/ui/components/ConsoleFilter.kt | 41 ++ .../lazy/emulate/ui/components/GameCard.kt | 104 ++++ .../ui/components/TouchControllerOverlay.kt | 286 +++++++++ .../lazy/emulate/ui/navigation/NavGraph.kt | 77 +++ .../com/lazy/emulate/ui/navigation/Screen.kt | 12 + .../controller/ControllerLayoutScreen.kt | 228 +++++++ .../emulate/ui/screens/game/GameScreen.kt | 181 ++++++ .../emulate/ui/screens/home/HomeScreen.kt | 164 ++++++ .../ui/screens/settings/SettingsScreen.kt | 195 ++++++ .../java/com/lazy/emulate/ui/theme/Color.kt | 20 + .../java/com/lazy/emulate/ui/theme/Theme.kt | 47 ++ .../java/com/lazy/emulate/ui/theme/Type.kt | 52 ++ .../res/drawable/ic_launcher_background.xml | 170 ++++++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-night/themes.xml | 6 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 6 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../java/com/lazy/emulate/ExampleUnitTest.kt | 17 + build.gradle.kts | 4 + gradle.properties | 23 + gradle/libs.versions.toml | 53 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++++ gradlew.bat | 94 +++ settings.gradle.kts | 23 + 67 files changed, 4520 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/cpp/CMakeLists.txt create mode 100644 app/src/main/cpp/audio_engine.cpp create mode 100644 app/src/main/cpp/audio_engine.h create mode 100644 app/src/main/cpp/jni_bridge.cpp create mode 100644 app/src/main/cpp/libretro.h create mode 100644 app/src/main/cpp/libretro_frontend.cpp create mode 100644 app/src/main/cpp/libretro_frontend.h create mode 100644 app/src/main/java/com/lazy/emulate/MainActivity.kt create mode 100644 app/src/main/java/com/lazy/emulate/data/model/Game.kt create mode 100644 app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt create mode 100644 app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt create mode 100644 app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt create mode 100644 app/src/main/java/com/lazy/emulate/input/ControllerManager.kt create mode 100644 app/src/main/java/com/lazy/emulate/input/GamepadButton.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/theme/Color.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/lazy/emulate/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4f6fe81 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.lazy.emulate" + compileSdk = 36 + + defaultConfig { + applicationId = "com.lazy.emulate" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1+" + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + debugImplementation(libs.compose.ui.tooling) + + // Lifecycle + implementation(libs.lifecycle.runtime.compose) + implementation(libs.lifecycle.viewmodel.compose) + + // Window Size Classes + implementation(libs.material3.wsc) + + // Coroutines + implementation(libs.coroutines.android) + + // DataStore + implementation(libs.datastore.prefs) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..19b5b4b --- /dev/null +++ b/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.lazy.emulate + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.lazy.emulate", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a465706 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..56086ad --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.22.1) +project(emulate_native) + +add_library(emulate_native SHARED + jni_bridge.cpp + libretro_frontend.cpp + audio_engine.cpp +) + +target_include_directories(emulate_native PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +find_library(log-lib log) +find_library(android-lib android) + +target_link_libraries(emulate_native + ${log-lib} + ${android-lib} + aaudio + dl +) + +target_compile_options(emulate_native PRIVATE -std=c++20 -Wall -O2) diff --git a/app/src/main/cpp/audio_engine.cpp b/app/src/main/cpp/audio_engine.cpp new file mode 100644 index 0000000..3057f02 --- /dev/null +++ b/app/src/main/cpp/audio_engine.cpp @@ -0,0 +1,114 @@ +#include "audio_engine.h" +#include +#include +#include + +#define LOG_TAG "AudioEngine" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// --- RingBuffer --- + +void RingBuffer::push(const int16_t* data, size_t frames) { + size_t wp = write_pos_.load(std::memory_order_relaxed); + size_t rp = read_pos_.load(std::memory_order_acquire); + + for (size_t i = 0; i < frames; i++) { + size_t next = (wp + 1) % CAPACITY; + if (next == rp) break; // full, drop samples + buffer_[wp * 2] = data[i * 2]; + buffer_[wp * 2 + 1] = data[i * 2 + 1]; + wp = next; + } + write_pos_.store(wp, std::memory_order_release); +} + +size_t RingBuffer::pop(int16_t* out, size_t frames) { + size_t rp = read_pos_.load(std::memory_order_relaxed); + size_t wp = write_pos_.load(std::memory_order_acquire); + + size_t read = 0; + while (read < frames && rp != wp) { + out[read * 2] = buffer_[rp * 2]; + out[read * 2 + 1] = buffer_[rp * 2 + 1]; + rp = (rp + 1) % CAPACITY; + read++; + } + read_pos_.store(rp, std::memory_order_release); + return read; +} + +size_t RingBuffer::available() const { + size_t wp = write_pos_.load(std::memory_order_acquire); + size_t rp = read_pos_.load(std::memory_order_acquire); + return (wp >= rp) ? (wp - rp) : (CAPACITY - rp + wp); +} + +// --- AudioEngine --- + +aaudio_data_callback_result_t AudioEngine::dataCallback( + AAudioStream* /*stream*/, void* userData, void* audioData, int32_t numFrames) { + auto* engine = static_cast(userData); + auto* output = static_cast(audioData); + + if (!engine->isEnabled()) { + memset(output, 0, numFrames * 2 * sizeof(int16_t)); + return AAUDIO_CALLBACK_RESULT_CONTINUE; + } + + size_t read = engine->ringBuffer().pop(output, numFrames); + // Fill remainder with silence to prevent noise + if (read < static_cast(numFrames)) { + memset(output + read * 2, 0, (numFrames - read) * 2 * sizeof(int16_t)); + } + return AAUDIO_CALLBACK_RESULT_CONTINUE; +} + +bool AudioEngine::start(int32_t sampleRate) { + AAudioStreamBuilder* builder = nullptr; + aaudio_result_t result = AAudio_createStreamBuilder(&builder); + if (result != AAUDIO_OK) { + LOGE("Failed to create stream builder: %d", result); + return false; + } + + AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT); + AAudioStreamBuilder_setSharingMode(builder, AAUDIO_SHARING_MODE_SHARED); + AAudioStreamBuilder_setSampleRate(builder, sampleRate); + AAudioStreamBuilder_setChannelCount(builder, 2); + AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16); + AAudioStreamBuilder_setPerformanceMode(builder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); + AAudioStreamBuilder_setDataCallback(builder, dataCallback, this); + + result = AAudioStreamBuilder_openStream(builder, &stream_); + AAudioStreamBuilder_delete(builder); + + if (result != AAUDIO_OK) { + LOGE("Failed to open stream: %d", result); + return false; + } + + result = AAudioStream_requestStart(stream_); + if (result != AAUDIO_OK) { + LOGE("Failed to start stream: %d", result); + AAudioStream_close(stream_); + stream_ = nullptr; + return false; + } + + LOGD("Audio started: %d Hz stereo", sampleRate); + return true; +} + +void AudioEngine::stop() { + if (stream_) { + AAudioStream_requestStop(stream_); + AAudioStream_close(stream_); + stream_ = nullptr; + LOGD("Audio stopped"); + } +} + +void AudioEngine::pushSamples(const int16_t* data, size_t frames) { + ring_buffer_.push(data, frames); +} diff --git a/app/src/main/cpp/audio_engine.h b/app/src/main/cpp/audio_engine.h new file mode 100644 index 0000000..f196df9 --- /dev/null +++ b/app/src/main/cpp/audio_engine.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +// Lock-free ring buffer for audio samples (interleaved stereo int16) +class RingBuffer { +public: + static constexpr size_t CAPACITY = 8192; // frames (each frame = 2 samples L+R) + + void push(const int16_t* data, size_t frames); + size_t pop(int16_t* out, size_t frames); + size_t available() const; + +private: + int16_t buffer_[CAPACITY * 2]{}; // stereo + std::atomic read_pos_{0}; + std::atomic write_pos_{0}; +}; + +class AudioEngine { +public: + bool start(int32_t sampleRate); + void stop(); + void pushSamples(const int16_t* data, size_t frames); + void setEnabled(bool enabled) { enabled_.store(enabled); } + + RingBuffer& ringBuffer() { return ring_buffer_; } + bool isEnabled() const { return enabled_.load(); } + +private: + AAudioStream* stream_ = nullptr; + RingBuffer ring_buffer_; + std::atomic enabled_{true}; + + static aaudio_data_callback_result_t dataCallback( + AAudioStream* stream, void* userData, void* audioData, int32_t numFrames); +}; diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp new file mode 100644 index 0000000..c44e231 --- /dev/null +++ b/app/src/main/cpp/jni_bridge.cpp @@ -0,0 +1,119 @@ +#include "libretro_frontend.h" + +#include +#include +#include + +#define LOG_TAG "JniBridge" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) + +extern "C" { + +JNIEXPORT jboolean JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadCore(JNIEnv* env, jobject, jstring corePath) { + const char* path = env->GetStringUTFChars(corePath, nullptr); + bool result = LibretroFrontend::instance().loadCore(path); + env->ReleaseStringUTFChars(corePath, path); + return result; +} + +JNIEXPORT jboolean JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadGame(JNIEnv* env, jobject, jstring romPath) { + const char* path = env->GetStringUTFChars(romPath, nullptr); + bool result = LibretroFrontend::instance().loadGame(path); + env->ReleaseStringUTFChars(romPath, path); + return result; +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeStart(JNIEnv*, jobject) { + LibretroFrontend::instance().start(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativePause(JNIEnv*, jobject) { + LibretroFrontend::instance().pause(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeResume(JNIEnv*, jobject) { + LibretroFrontend::instance().resume(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeStop(JNIEnv*, jobject) { + LibretroFrontend::instance().stop(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeReset(JNIEnv*, jobject) { + LibretroFrontend::instance().reset(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSurface(JNIEnv* env, jobject, jobject surface) { + if (surface) { + ANativeWindow* window = ANativeWindow_fromSurface(env, surface); + LibretroFrontend::instance().setWindow(window); + ANativeWindow_release(window); // setWindow does its own acquire + } else { + LibretroFrontend::instance().releaseWindow(); + } +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeReleaseSurface(JNIEnv*, jobject) { + LibretroFrontend::instance().releaseWindow(); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetButtonState(JNIEnv*, jobject, jint state) { + LibretroFrontend::instance().setButtonState(static_cast(state)); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetAnalogState(JNIEnv*, jobject, jint stick, jshort x, jshort y) { + LibretroFrontend::instance().setAnalogState(stick, x, y); +} + +JNIEXPORT jboolean JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSaveState(JNIEnv* env, jobject, jstring path) { + const char* p = env->GetStringUTFChars(path, nullptr); + bool result = LibretroFrontend::instance().saveState(p); + env->ReleaseStringUTFChars(path, p); + return result; +} + +JNIEXPORT jboolean JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadState(JNIEnv* env, jobject, jstring path) { + const char* p = env->GetStringUTFChars(path, nullptr); + bool result = LibretroFrontend::instance().loadState(p); + env->ReleaseStringUTFChars(path, p); + return result; +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetAudioEnabled(JNIEnv*, jobject, jboolean enabled) { + LibretroFrontend::instance().setAudioEnabled(enabled); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetFrameSkip(JNIEnv*, jobject, jint skip) { + LibretroFrontend::instance().setFrameSkip(skip); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSystemDir(JNIEnv* env, jobject, jstring dir) { + const char* d = env->GetStringUTFChars(dir, nullptr); + LibretroFrontend::instance().setSystemDir(d); + env->ReleaseStringUTFChars(dir, d); +} + +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, jobject, jstring dir) { + const char* d = env->GetStringUTFChars(dir, nullptr); + LibretroFrontend::instance().setSaveDir(d); + env->ReleaseStringUTFChars(dir, d); +} + +} // extern "C" diff --git a/app/src/main/cpp/libretro.h b/app/src/main/cpp/libretro.h new file mode 100644 index 0000000..df1ebd8 --- /dev/null +++ b/app/src/main/cpp/libretro.h @@ -0,0 +1,245 @@ +/* Minimal vendored libretro.h - stable C API for emulator cores. + * Based on the canonical libretro API specification. + * Only includes types/constants needed by our frontend. */ + +#ifndef LIBRETRO_H__ +#define LIBRETRO_H__ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RETRO_API_VERSION 1 + +/* Pixel formats */ +#define RETRO_PIXEL_FORMAT_0RGB1555 0 +#define RETRO_PIXEL_FORMAT_XRGB8888 1 +#define RETRO_PIXEL_FORMAT_RGB565 2 + +/* Device types */ +#define RETRO_DEVICE_NONE 0 +#define RETRO_DEVICE_JOYPAD 1 +#define RETRO_DEVICE_MOUSE 2 +#define RETRO_DEVICE_KEYBOARD 3 +#define RETRO_DEVICE_LIGHTGUN 4 +#define RETRO_DEVICE_ANALOG 5 + +/* Joypad button IDs */ +#define RETRO_DEVICE_ID_JOYPAD_B 0 +#define RETRO_DEVICE_ID_JOYPAD_Y 1 +#define RETRO_DEVICE_ID_JOYPAD_SELECT 2 +#define RETRO_DEVICE_ID_JOYPAD_START 3 +#define RETRO_DEVICE_ID_JOYPAD_UP 4 +#define RETRO_DEVICE_ID_JOYPAD_DOWN 5 +#define RETRO_DEVICE_ID_JOYPAD_LEFT 6 +#define RETRO_DEVICE_ID_JOYPAD_RIGHT 7 +#define RETRO_DEVICE_ID_JOYPAD_A 8 +#define RETRO_DEVICE_ID_JOYPAD_X 9 +#define RETRO_DEVICE_ID_JOYPAD_L 10 +#define RETRO_DEVICE_ID_JOYPAD_R 11 +#define RETRO_DEVICE_ID_JOYPAD_L2 12 +#define RETRO_DEVICE_ID_JOYPAD_R2 13 +#define RETRO_DEVICE_ID_JOYPAD_L3 14 +#define RETRO_DEVICE_ID_JOYPAD_R3 15 + +/* Analog indices */ +#define RETRO_DEVICE_INDEX_ANALOG_LEFT 0 +#define RETRO_DEVICE_INDEX_ANALOG_RIGHT 1 +#define RETRO_DEVICE_ID_ANALOG_X 0 +#define RETRO_DEVICE_ID_ANALOG_Y 1 + +/* Memory types */ +#define RETRO_MEMORY_SAVE_RAM 0 +#define RETRO_MEMORY_RTC 1 +#define RETRO_MEMORY_SYSTEM_RAM 2 +#define RETRO_MEMORY_VIDEO_RAM 3 + +/* Environment callback commands */ +#define RETRO_ENVIRONMENT_SET_ROTATION 1 +#define RETRO_ENVIRONMENT_GET_OVERSCAN 2 +#define RETRO_ENVIRONMENT_GET_CAN_DUPE 3 +#define RETRO_ENVIRONMENT_SET_MESSAGE 6 +#define RETRO_ENVIRONMENT_SHUTDOWN 7 +#define RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL 8 +#define RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY 9 +#define RETRO_ENVIRONMENT_SET_PIXEL_FORMAT 10 +#define RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS 11 +#define RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK 12 +#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE 13 +#define RETRO_ENVIRONMENT_SET_HW_RENDER 14 +#define RETRO_ENVIRONMENT_GET_VARIABLE 15 +#define RETRO_ENVIRONMENT_SET_VARIABLES 16 +#define RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE 17 +#define RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME 18 +#define RETRO_ENVIRONMENT_GET_LIBRETRO_PATH 19 +#define RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK 21 +#define RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK 22 +#define RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE 23 +#define RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES 24 +#define RETRO_ENVIRONMENT_GET_LOG_INTERFACE 27 +#define RETRO_ENVIRONMENT_GET_PERF_INTERFACE 28 +#define RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY 30 +#define RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY 31 +#define RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO 32 +#define RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO 34 +#define RETRO_ENVIRONMENT_SET_CONTROLLER_INFO 35 +#define RETRO_ENVIRONMENT_SET_MEMORY_MAPS 36 +#define RETRO_ENVIRONMENT_SET_GEOMETRY 37 +#define RETRO_ENVIRONMENT_GET_USERNAME 38 +#define RETRO_ENVIRONMENT_GET_LANGUAGE 39 +#define RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS 42 +#define RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS 44 +#define RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE 47 +#define RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION 52 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS 53 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL 54 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY 55 +#define RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER 56 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 67 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL 68 +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK 69 + +/* Log levels */ +enum retro_log_level { + RETRO_LOG_DEBUG = 0, + RETRO_LOG_INFO, + RETRO_LOG_WARN, + RETRO_LOG_ERROR +}; + +typedef void (*retro_log_printf_t)(enum retro_log_level level, const char *fmt, ...); + +struct retro_log_callback { + retro_log_printf_t log; +}; + +struct retro_variable { + const char *key; + const char *value; +}; + +struct retro_game_info { + const char *path; + const void *data; + size_t size; + const char *meta; +}; + +struct retro_system_timing { + double fps; + double sample_rate; +}; + +struct retro_game_geometry { + unsigned base_width; + unsigned base_height; + unsigned max_width; + unsigned max_height; + float aspect_ratio; +}; + +struct retro_system_av_info { + struct retro_game_geometry geometry; + struct retro_system_timing timing; +}; + +struct retro_system_info { + const char *library_name; + const char *library_version; + const char *valid_extensions; + bool need_fullpath; + bool block_extract; +}; + +struct retro_message { + const char *msg; + unsigned frames; +}; + +struct retro_input_descriptor { + unsigned port; + unsigned device; + unsigned index; + unsigned id; + const char *description; +}; + +struct retro_core_option_value { + const char *value; + const char *label; +}; + +struct retro_core_option_definition { + const char *key; + const char *desc; + const char *info; + struct retro_core_option_value values[128]; + const char *default_value; +}; + +struct retro_core_option_v2_category { + const char *key; + const char *desc; + const char *info; +}; + +struct retro_core_option_v2_definition { + const char *key; + const char *desc; + const char *desc_categorized; + const char *info; + const char *info_categorized; + const char *category_key; + struct retro_core_option_value values[128]; + const char *default_value; +}; + +struct retro_core_options_v2 { + struct retro_core_option_v2_category *categories; + struct retro_core_option_v2_definition *definitions; +}; + +struct retro_core_options_v2_intl { + struct retro_core_options_v2 *us; + struct retro_core_options_v2 *local; +}; + +/* Callback types */ +typedef bool (*retro_environment_t)(unsigned cmd, void *data); +typedef void (*retro_video_refresh_t)(const void *data, unsigned width, unsigned height, size_t pitch); +typedef void (*retro_audio_sample_t)(int16_t left, int16_t right); +typedef size_t (*retro_audio_sample_batch_t)(const int16_t *data, size_t frames); +typedef void (*retro_input_poll_t)(void); +typedef int16_t (*retro_input_state_t)(unsigned port, unsigned device, unsigned index, unsigned id); + +/* Core function pointer types */ +typedef void (*retro_set_environment_t)(retro_environment_t); +typedef void (*retro_set_video_refresh_t)(retro_video_refresh_t); +typedef void (*retro_set_audio_sample_t)(retro_audio_sample_t); +typedef void (*retro_set_audio_sample_batch_t)(retro_audio_sample_batch_t); +typedef void (*retro_set_input_poll_t)(retro_input_poll_t); +typedef void (*retro_set_input_state_t)(retro_input_state_t); +typedef void (*retro_init_t)(void); +typedef void (*retro_deinit_t)(void); +typedef unsigned (*retro_api_version_t)(void); +typedef void (*retro_get_system_info_t)(struct retro_system_info *info); +typedef void (*retro_get_system_av_info_t)(struct retro_system_av_info *info); +typedef void (*retro_set_controller_port_device_t)(unsigned port, unsigned device); +typedef void (*retro_reset_t)(void); +typedef void (*retro_run_t)(void); +typedef bool (*retro_load_game_t)(const struct retro_game_info *game); +typedef void (*retro_unload_game_t)(void); +typedef size_t (*retro_serialize_size_t)(void); +typedef bool (*retro_serialize_t)(void *data, size_t size); +typedef bool (*retro_unserialize_t)(const void *data, size_t size); +typedef void *(*retro_get_memory_data_t)(unsigned id); +typedef size_t (*retro_get_memory_size_t)(unsigned id); + +#ifdef __cplusplus +} +#endif + +#endif /* LIBRETRO_H__ */ diff --git a/app/src/main/cpp/libretro_frontend.cpp b/app/src/main/cpp/libretro_frontend.cpp new file mode 100644 index 0000000..87b7e8e --- /dev/null +++ b/app/src/main/cpp/libretro_frontend.cpp @@ -0,0 +1,556 @@ +#include "libretro_frontend.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "LibretroFrontend" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +LibretroFrontend& LibretroFrontend::instance() { + static LibretroFrontend inst; + return inst; +} + +LibretroFrontend::~LibretroFrontend() { + stop(); + unloadCore(); + releaseWindow(); +} + +// --- Core Loading --- + +bool LibretroFrontend::loadCore(const char* soPath) { + unloadCore(); + + LOGI("Loading core: %s", soPath); + core_handle_ = dlopen(soPath, RTLD_LAZY); + if (!core_handle_) { + LOGE("dlopen failed: %s", dlerror()); + return false; + } + + #define LOAD_SYM(field, name) \ + field = (decltype(field))dlsym(core_handle_, #name); \ + if (!field) { LOGE("Missing symbol: %s", #name); unloadCore(); return false; } + + LOAD_SYM(core_init_, retro_init); + LOAD_SYM(core_deinit_, retro_deinit); + LOAD_SYM(core_api_version_, retro_api_version); + LOAD_SYM(core_get_system_info_, retro_get_system_info); + LOAD_SYM(core_get_system_av_info_, retro_get_system_av_info); + LOAD_SYM(core_set_environment_, retro_set_environment); + LOAD_SYM(core_set_video_refresh_, retro_set_video_refresh); + LOAD_SYM(core_set_audio_sample_, retro_set_audio_sample); + LOAD_SYM(core_set_audio_sample_batch_, retro_set_audio_sample_batch); + LOAD_SYM(core_set_input_poll_, retro_set_input_poll); + LOAD_SYM(core_set_input_state_, retro_set_input_state); + LOAD_SYM(core_set_controller_port_device_, retro_set_controller_port_device); + LOAD_SYM(core_load_game_, retro_load_game); + LOAD_SYM(core_unload_game_, retro_unload_game); + LOAD_SYM(core_run_, retro_run); + LOAD_SYM(core_reset_, retro_reset); + LOAD_SYM(core_serialize_size_, retro_serialize_size); + LOAD_SYM(core_serialize_, retro_serialize); + LOAD_SYM(core_unserialize_, retro_unserialize); + LOAD_SYM(core_get_memory_data_, retro_get_memory_data); + LOAD_SYM(core_get_memory_size_, retro_get_memory_size); + #undef LOAD_SYM + + // Set callbacks before init + core_set_environment_(environmentCallback); + core_init_(); + core_set_video_refresh_(videoRefreshCallback); + core_set_audio_sample_(audioSampleCallback); + core_set_audio_sample_batch_(audioSampleBatchCallback); + core_set_input_poll_(inputPollCallback); + core_set_input_state_(inputStateCallback); + + // Set controller to analog pad (DualShock) for PS1 + core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG); + + retro_system_info sys_info{}; + core_get_system_info_(&sys_info); + LOGI("Core loaded: %s %s", sys_info.library_name, sys_info.library_version); + LOGI(" need_fullpath: %d, block_extract: %d", sys_info.need_fullpath, sys_info.block_extract); + + // Set default core options for pcsx_rearmed + core_options_["pcsx_rearmed_drc"] = "enabled"; + core_options_["pcsx_rearmed_pad1type"] = "analog"; + core_options_["pcsx_rearmed_frameskip_type"] = "disabled"; + core_options_["pcsx_rearmed_region"] = "auto"; + core_options_["pcsx_rearmed_memcard2"] = "disabled"; + + return true; +} + +bool LibretroFrontend::loadGame(const char* romPath) { + if (!core_handle_) { + LOGE("No core loaded"); + return false; + } + + LOGI("Loading game: %s", romPath); + + // pcsx_rearmed uses need_fullpath=true, so we just pass the path + retro_game_info game_info{}; + game_info.path = romPath; + game_info.data = nullptr; + game_info.size = 0; + game_info.meta = nullptr; + + if (!core_load_game_(&game_info)) { + LOGE("retro_load_game failed"); + return false; + } + + game_loaded_.store(true); + + // Get AV info for timing and geometry + retro_system_av_info av_info{}; + core_get_system_av_info_(&av_info); + target_fps_ = av_info.timing.fps; + double sample_rate = av_info.timing.sample_rate; + + LOGI("Game loaded - %ux%u @ %.1f fps, audio %.0f Hz", + av_info.geometry.base_width, av_info.geometry.base_height, + target_fps_, sample_rate); + + // Start audio + audio_engine_.start(static_cast(sample_rate)); + + return true; +} + +// --- Lifecycle --- + +void LibretroFrontend::start() { + if (running_.load()) return; + if (!game_loaded_.load()) { + LOGE("Cannot start: no game loaded"); + return; + } + + running_.store(true); + paused_.store(false); + emu_thread_ = std::thread(&LibretroFrontend::runLoop, this); + LOGI("Emulation started"); +} + +void LibretroFrontend::pause() { + paused_.store(true); + LOGD("Emulation paused"); +} + +void LibretroFrontend::resume() { + paused_.store(false); + LOGD("Emulation resumed"); +} + +void LibretroFrontend::stop() { + running_.store(false); + paused_.store(false); + + if (emu_thread_.joinable()) { + emu_thread_.join(); + } + + // Save SRAM before unloading + if (game_loaded_.load() && core_handle_) { + void* sram = core_get_memory_data_(RETRO_MEMORY_SAVE_RAM); + size_t sram_size = core_get_memory_size_(RETRO_MEMORY_SAVE_RAM); + if (sram && sram_size > 0 && !save_dir_.empty()) { + std::string save_path = save_dir_ + "/game.srm"; + std::ofstream out(save_path, std::ios::binary); + if (out) { + out.write(static_cast(sram), sram_size); + LOGI("SRAM saved: %zu bytes", sram_size); + } + } + + core_unload_game_(); + game_loaded_.store(false); + } + + audio_engine_.stop(); + LOGI("Emulation stopped"); +} + +void LibretroFrontend::reset() { + if (core_handle_ && game_loaded_.load()) { + core_reset_(); + LOGI("Emulation reset"); + } +} + +void LibretroFrontend::unloadCore() { + if (core_handle_) { + if (game_loaded_.load()) { + core_unload_game_(); + game_loaded_.store(false); + } + core_deinit_(); + dlclose(core_handle_); + core_handle_ = nullptr; + LOGI("Core unloaded"); + } +} + +// --- Emulation Loop --- + +void LibretroFrontend::runLoop() { + using clock = std::chrono::high_resolution_clock; + auto frame_duration = std::chrono::duration_cast( + std::chrono::duration(1.0 / target_fps_)); + + while (running_.load()) { + if (paused_.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + continue; + } + + auto frame_start = clock::now(); + core_run_(); + + // Frame pacing + auto elapsed = clock::now() - frame_start; + if (elapsed < frame_duration) { + std::this_thread::sleep_for(frame_duration - elapsed); + } + } +} + +// --- Video --- + +void LibretroFrontend::setWindow(ANativeWindow* window) { + std::lock_guard lock(window_mutex_); + if (window_) { + ANativeWindow_release(window_); + } + window_ = window; + if (window_) { + ANativeWindow_acquire(window_); + } +} + +void LibretroFrontend::releaseWindow() { + std::lock_guard lock(window_mutex_); + if (window_) { + ANativeWindow_release(window_); + window_ = nullptr; + } +} + +void LibretroFrontend::videoRefreshCallback(const void* data, unsigned width, unsigned height, size_t pitch) { + if (!data) return; // dupe frame + + auto& fe = instance(); + std::lock_guard lock(fe.window_mutex_); + if (!fe.window_) return; + + int32_t windowFormat; + int bytesPerPixel; + switch (fe.pixel_format_) { + case RETRO_PIXEL_FORMAT_RGB565: + windowFormat = AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM; + bytesPerPixel = 2; + break; + case RETRO_PIXEL_FORMAT_XRGB8888: + windowFormat = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM; + bytesPerPixel = 4; + break; + case RETRO_PIXEL_FORMAT_0RGB1555: + default: + windowFormat = AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM; + bytesPerPixel = 2; + break; + } + + ANativeWindow_setBuffersGeometry(fe.window_, width, height, windowFormat); + + ANativeWindow_Buffer buffer; + if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return; + + auto* dst = static_cast(buffer.bits); + auto* src = static_cast(data); + size_t dstStride = buffer.stride * bytesPerPixel; + size_t copyWidth = width * bytesPerPixel; + + for (unsigned y = 0; y < height; y++) { + memcpy(dst + y * dstStride, src + y * pitch, copyWidth); + } + + ANativeWindow_unlockAndPost(fe.window_); +} + +// --- Audio --- + +size_t LibretroFrontend::audioSampleBatchCallback(const int16_t* data, size_t frames) { + instance().audio_engine_.pushSamples(data, frames); + return frames; +} + +void LibretroFrontend::audioSampleCallback(int16_t left, int16_t right) { + int16_t buf[2] = {left, right}; + instance().audio_engine_.pushSamples(buf, 1); +} + +// --- Input --- + +void LibretroFrontend::setButtonState(uint16_t state) { + button_state_.store(state); +} + +void LibretroFrontend::setAnalogState(int stick, int16_t x, int16_t y) { + if (stick == 0) { + analog_lx_.store(x); + analog_ly_.store(y); + } else { + analog_rx_.store(x); + analog_ry_.store(y); + } +} + +void LibretroFrontend::inputPollCallback() { + // No-op: input state is always current via atomics +} + +int16_t LibretroFrontend::inputStateCallback(unsigned port, unsigned device, unsigned index, unsigned id) { + if (port != 0) return 0; + + auto& fe = instance(); + + if (device == RETRO_DEVICE_JOYPAD) { + return (fe.button_state_.load() >> id) & 1; + } + + if (device == RETRO_DEVICE_ANALOG) { + if (index == RETRO_DEVICE_INDEX_ANALOG_LEFT) { + return (id == RETRO_DEVICE_ID_ANALOG_X) ? fe.analog_lx_.load() : fe.analog_ly_.load(); + } else if (index == RETRO_DEVICE_INDEX_ANALOG_RIGHT) { + return (id == RETRO_DEVICE_ID_ANALOG_X) ? fe.analog_rx_.load() : fe.analog_ry_.load(); + } + } + + return 0; +} + +// --- Environment --- + +bool LibretroFrontend::environmentCallback(unsigned cmd, void* data) { + auto& fe = instance(); + + switch (cmd) { + case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: { + auto** dir = static_cast(data); + *dir = fe.system_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: { + auto** dir = static_cast(data); + *dir = fe.save_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY: { + auto** dir = static_cast(data); + *dir = fe.system_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: { + auto* fmt = static_cast(data); + fe.pixel_format_ = *fmt; + LOGD("Pixel format set to: %u", *fmt); + return true; + } + case RETRO_ENVIRONMENT_GET_VARIABLE: { + auto* var = static_cast(data); + if (var->key) { + auto it = fe.core_options_.find(var->key); + if (it != fe.core_options_.end()) { + var->value = it->second.c_str(); + return true; + } + } + var->value = nullptr; + return false; + } + case RETRO_ENVIRONMENT_SET_VARIABLES: { + // Core is telling us what variables it supports + auto* vars = static_cast(data); + while (vars && vars->key) { + // Only set defaults if we haven't already set a value + if (fe.core_options_.find(vars->key) == fe.core_options_.end()) { + // Parse default from the description (format: "Description; val1|val2|val3") + if (vars->value) { + std::string desc(vars->value); + auto semi = desc.find("; "); + if (semi != std::string::npos) { + std::string values = desc.substr(semi + 2); + auto pipe = values.find('|'); + std::string first = (pipe != std::string::npos) ? + values.substr(0, pipe) : values; + fe.core_options_[vars->key] = first; + } + } + } + vars++; + } + return true; + } + case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: { + auto* updated = static_cast(data); + *updated = fe.options_updated_.exchange(false); + return true; + } + case RETRO_ENVIRONMENT_GET_CAN_DUPE: { + auto* can = static_cast(data); + *can = true; + return true; + } + case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: { + auto* cb = static_cast(data); + cb->log = logCallback; + return true; + } + case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS: + case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: + case RETRO_ENVIRONMENT_SET_MEMORY_MAPS: + case RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME: + case RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO: + case RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY: + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK: + return true; // acknowledge but ignore + + case RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION: { + auto* version = static_cast(data); + *version = 0; // use v0 variables API (simplest) + return true; + } + case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: { + auto* bits = static_cast(data); + *bits = 3; // enable both audio (bit 1) and video (bit 0) + return true; + } + case RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES: { + auto* caps = static_cast(data); + *caps = (1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG); + return true; + } + case RETRO_ENVIRONMENT_SET_GEOMETRY: { + auto* geom = static_cast(data); + LOGD("Geometry changed: %ux%u", geom->base_width, geom->base_height); + return true; + } + case RETRO_ENVIRONMENT_GET_LANGUAGE: { + auto* lang = static_cast(data); + *lang = 0; // RETRO_LANGUAGE_ENGLISH + return true; + } + default: + LOGD("Unhandled env cmd: %u", cmd); + return false; + } +} + +void LibretroFrontend::logCallback(enum retro_log_level level, const char* fmt, ...) { + va_list args; + va_start(args, fmt); + + int priority; + switch (level) { + case RETRO_LOG_DEBUG: priority = ANDROID_LOG_DEBUG; break; + case RETRO_LOG_INFO: priority = ANDROID_LOG_INFO; break; + case RETRO_LOG_WARN: priority = ANDROID_LOG_WARN; break; + case RETRO_LOG_ERROR: priority = ANDROID_LOG_ERROR; break; + default: priority = ANDROID_LOG_VERBOSE; break; + } + + __android_log_vprint(priority, "RetroCore", fmt, args); + va_end(args); +} + +// --- Save States --- + +bool LibretroFrontend::saveState(const char* path) { + if (!core_handle_ || !game_loaded_.load()) return false; + + size_t size = core_serialize_size_(); + if (size == 0) return false; + + std::vector buf(size); + if (!core_serialize_(buf.data(), size)) { + LOGE("retro_serialize failed"); + return false; + } + + std::ofstream out(path, std::ios::binary); + if (!out) { + LOGE("Failed to open save state file: %s", path); + return false; + } + out.write(reinterpret_cast(buf.data()), size); + LOGI("State saved: %zu bytes -> %s", size, path); + return true; +} + +bool LibretroFrontend::loadState(const char* path) { + if (!core_handle_ || !game_loaded_.load()) return false; + + std::ifstream in(path, std::ios::binary | std::ios::ate); + if (!in) { + LOGE("Failed to open state file: %s", path); + return false; + } + + size_t size = in.tellg(); + in.seekg(0); + + std::vector buf(size); + in.read(reinterpret_cast(buf.data()), size); + + if (!core_unserialize_(buf.data(), size)) { + LOGE("retro_unserialize failed"); + return false; + } + + LOGI("State loaded: %zu bytes <- %s", size, path); + return true; +} + +// --- Config --- + +void LibretroFrontend::setAudioEnabled(bool enabled) { + audio_engine_.setEnabled(enabled); +} + +void LibretroFrontend::setFrameSkip(int skip) { + frame_skip_ = skip; + if (skip > 0) { + core_options_["pcsx_rearmed_frameskip_type"] = "auto"; + core_options_["pcsx_rearmed_frameskip_threshold"] = "33"; + } else { + core_options_["pcsx_rearmed_frameskip_type"] = "disabled"; + } + options_updated_.store(true); +} + +void LibretroFrontend::setSystemDir(const char* dir) { + system_dir_ = dir; +} + +void LibretroFrontend::setSaveDir(const char* dir) { + save_dir_ = dir; +} diff --git a/app/src/main/cpp/libretro_frontend.h b/app/src/main/cpp/libretro_frontend.h new file mode 100644 index 0000000..bc6d5d8 --- /dev/null +++ b/app/src/main/cpp/libretro_frontend.h @@ -0,0 +1,113 @@ +#pragma once + +#include "libretro.h" +#include "audio_engine.h" + +#include +#include +#include +#include +#include +#include + +class LibretroFrontend { +public: + static LibretroFrontend& instance(); + + bool loadCore(const char* soPath); + bool loadGame(const char* romPath); + void start(); + void pause(); + void resume(); + void stop(); + void reset(); + + void setWindow(ANativeWindow* window); + void releaseWindow(); + + void setButtonState(uint16_t state); + void setAnalogState(int stick, int16_t x, int16_t y); + + bool saveState(const char* path); + bool loadState(const char* path); + + void setAudioEnabled(bool enabled); + void setFrameSkip(int skip); + + void setSystemDir(const char* dir); + void setSaveDir(const char* dir); + + bool isRunning() const { return running_.load(); } + +private: + LibretroFrontend() = default; + ~LibretroFrontend(); + + void runLoop(); + void unloadCore(); + + // Libretro callbacks (static because libretro uses C function pointers) + static bool environmentCallback(unsigned cmd, void* data); + static void videoRefreshCallback(const void* data, unsigned width, unsigned height, size_t pitch); + static size_t audioSampleBatchCallback(const int16_t* data, size_t frames); + static void audioSampleCallback(int16_t left, int16_t right); + static void inputPollCallback(); + static int16_t inputStateCallback(unsigned port, unsigned device, unsigned index, unsigned id); + static void logCallback(enum retro_log_level level, const char* fmt, ...); + + // Core function pointers (resolved via dlsym) + void* core_handle_ = nullptr; + + retro_init_t core_init_ = nullptr; + retro_deinit_t core_deinit_ = nullptr; + retro_api_version_t core_api_version_ = nullptr; + retro_get_system_info_t core_get_system_info_ = nullptr; + retro_get_system_av_info_t core_get_system_av_info_ = nullptr; + retro_set_environment_t core_set_environment_ = nullptr; + retro_set_video_refresh_t core_set_video_refresh_ = nullptr; + retro_set_audio_sample_t core_set_audio_sample_ = nullptr; + retro_set_audio_sample_batch_t core_set_audio_sample_batch_ = nullptr; + retro_set_input_poll_t core_set_input_poll_ = nullptr; + retro_set_input_state_t core_set_input_state_ = nullptr; + retro_set_controller_port_device_t core_set_controller_port_device_ = nullptr; + retro_load_game_t core_load_game_ = nullptr; + retro_unload_game_t core_unload_game_ = nullptr; + retro_run_t core_run_ = nullptr; + retro_reset_t core_reset_ = nullptr; + retro_serialize_size_t core_serialize_size_ = nullptr; + retro_serialize_t core_serialize_ = nullptr; + retro_unserialize_t core_unserialize_ = nullptr; + retro_get_memory_data_t core_get_memory_data_ = nullptr; + retro_get_memory_size_t core_get_memory_size_ = nullptr; + + // State + std::atomic running_{false}; + std::atomic paused_{false}; + std::atomic game_loaded_{false}; + std::thread emu_thread_; + + // Video + ANativeWindow* window_ = nullptr; + std::mutex window_mutex_; + unsigned pixel_format_ = RETRO_PIXEL_FORMAT_RGB565; + + // Audio + AudioEngine audio_engine_; + + // Input + std::atomic button_state_{0}; + std::atomic analog_lx_{0}, analog_ly_{0}; + std::atomic analog_rx_{0}, analog_ry_{0}; + + // Paths + std::string system_dir_; + std::string save_dir_; + + // Frame timing + double target_fps_ = 60.0; + int frame_skip_ = 0; + + // Core options + std::unordered_map core_options_; + std::atomic options_updated_{false}; +}; diff --git a/app/src/main/java/com/lazy/emulate/MainActivity.kt b/app/src/main/java/com/lazy/emulate/MainActivity.kt new file mode 100644 index 0000000..29b2a6a --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/MainActivity.kt @@ -0,0 +1,89 @@ +package com.lazy.emulate + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.navigation.compose.rememberNavController +import com.lazy.emulate.data.repository.GameRepository +import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.ui.navigation.EmulateNavGraph +import com.lazy.emulate.ui.theme.EmulateTheme + +class MainActivity : ComponentActivity() { + + private lateinit var controllerManager: ControllerManager + private lateinit var gameRepository: GameRepository + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + requestAllFilesPermission() + + controllerManager = ControllerManager(this) + gameRepository = GameRepository(this) + + setContent { + EmulateTheme { + val windowSizeClass = calculateWindowSizeClass(this) + val navController = rememberNavController() + + EmulateNavGraph( + navController = navController, + windowSizeClass = windowSizeClass, + gameRepository = gameRepository, + controllerManager = controllerManager + ) + } + } + } + + override fun onResume() { + super.onResume() + controllerManager.start() + if (Environment.isExternalStorageManager()) { + gameRepository.scanGameFolders() + } + } + + override fun onPause() { + super.onPause() + controllerManager.stop() + } + + private fun requestAllFilesPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:$packageName") + ) + startActivity(intent) + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (event != null && controllerManager.handleKeyEvent(event)) return true + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + if (event != null && controllerManager.handleKeyEvent(event)) return true + return super.onKeyUp(keyCode, event) + } + + override fun onGenericMotionEvent(event: MotionEvent?): Boolean { + if (event != null && controllerManager.handleMotionEvent(event)) return true + return super.onGenericMotionEvent(event) + } +} diff --git a/app/src/main/java/com/lazy/emulate/data/model/Game.kt b/app/src/main/java/com/lazy/emulate/data/model/Game.kt new file mode 100644 index 0000000..c37c33f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/data/model/Game.kt @@ -0,0 +1,14 @@ +package com.lazy.emulate.data.model + +import com.lazy.emulate.emulation.ConsoleType + +data class Game( + val id: String, + val title: String, + val romPath: String, + val consoleType: ConsoleType, + val coverArtPath: String? = null, + val lastPlayed: Long? = null, + val favorite: Boolean = false, + val playTimeSeconds: Long = 0 +) diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt new file mode 100644 index 0000000..a177b4f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt @@ -0,0 +1,119 @@ +package com.lazy.emulate.data.repository + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import com.lazy.emulate.data.model.Game +import com.lazy.emulate.emulation.ConsoleType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.util.UUID + +class GameRepository(private val context: Context) { + + private val _games = MutableStateFlow>(emptyList()) + val games: StateFlow> = _games.asStateFlow() + + private val gamesRoot = File(Environment.getExternalStorageDirectory(), "Games") + + // Maps folder names to console types + private val folderToConsole = mapOf( + "PS" to ConsoleType.PS1, + "PS1" to ConsoleType.PS1, + "PS2" to ConsoleType.PS2, + "NES" to ConsoleType.NES, + "SNES" to ConsoleType.SNES, + "N64" to ConsoleType.N64, + "Genesis" to ConsoleType.GENESIS, + "GEN" to ConsoleType.GENESIS, + ) + + fun scanGameFolders() { + if (!gamesRoot.exists()) return + + val scanned = mutableListOf() + val existingPaths = _games.value.map { it.romPath }.toSet() + + gamesRoot.listFiles()?.forEach { folder -> + if (!folder.isDirectory) return@forEach + val consoleType = folderToConsole[folder.name.uppercase()] + ?: folderToConsole[folder.name] + ?: return@forEach + + val files = folder.listFiles()?.filter { it.isFile } ?: return@forEach + // For disc-based consoles, prefer .cue files and skip companion .bin files + val cueFiles = files.filter { it.extension.lowercase() == "cue" } + val cueBasenames = cueFiles.map { it.nameWithoutExtension.lowercase() }.toSet() + + files.forEach { file -> + if (file.absolutePath in existingPaths) return@forEach + val ext = file.extension.lowercase() + // Skip .bin files that have a matching .cue + if (ext == "bin" && file.nameWithoutExtension.lowercase() in cueBasenames) { + return@forEach + } + if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") { + scanned.add( + Game( + id = UUID.randomUUID().toString(), + title = file.nameWithoutExtension, + romPath = file.absolutePath, + consoleType = consoleType + ) + ) + } + } + } + + if (scanned.isNotEmpty()) { + _games.value = _games.value + scanned + } + } + + fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game { + val game = Game( + id = UUID.randomUUID().toString(), + title = title, + romPath = romPath, + consoleType = consoleType + ) + _games.value = _games.value + game + return game + } + + fun addGameFromUri(uri: Uri, consoleType: ConsoleType): Game? { + val fileName = getFileName(uri) ?: return null + val title = fileName.substringBeforeLast(".") + return addGame(title, uri.toString(), consoleType) + } + + fun removeGame(gameId: String) { + _games.value = _games.value.filter { it.id != gameId } + } + + fun toggleFavorite(gameId: String) { + _games.value = _games.value.map { game -> + if (game.id == gameId) game.copy(favorite = !game.favorite) else game + } + } + + fun updateLastPlayed(gameId: String) { + _games.value = _games.value.map { game -> + if (game.id == gameId) game.copy(lastPlayed = System.currentTimeMillis()) else game + } + } + + fun getGamesByConsole(consoleType: ConsoleType): List { + return _games.value.filter { it.consoleType == consoleType } + } + + private fun getFileName(uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) cursor.getString(nameIndex) else null + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt new file mode 100644 index 0000000..65896c9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt @@ -0,0 +1,46 @@ +package com.lazy.emulate.emulation + +enum class ConsoleType( + val displayName: String, + val manufacturer: String, + val fileExtensions: List, + val isImplemented: Boolean = false +) { + PS1( + displayName = "PlayStation", + manufacturer = "Sony", + fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"), + isImplemented = true + ), + NES( + displayName = "Nintendo Entertainment System", + manufacturer = "Nintendo", + fileExtensions = listOf("nes", "unf", "unif") + ), + SNES( + displayName = "Super Nintendo", + manufacturer = "Nintendo", + fileExtensions = listOf("sfc", "smc", "fig", "swc") + ), + N64( + displayName = "Nintendo 64", + manufacturer = "Nintendo", + fileExtensions = listOf("n64", "z64", "v64", "ndd") + ), + GENESIS( + displayName = "Sega Genesis", + manufacturer = "Sega", + fileExtensions = listOf("gen", "md", "smd", "bin") + ), + PS2( + displayName = "PlayStation 2", + manufacturer = "Sony", + fileExtensions = listOf("iso", "bin", "chd", "cso") + ); + + companion object { + fun fromExtension(ext: String): List { + return entries.filter { ext.lowercase() in it.fileExtensions } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt new file mode 100644 index 0000000..6662b9d --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt @@ -0,0 +1,36 @@ +package com.lazy.emulate.emulation + +import android.content.Context +import com.lazy.emulate.emulation.cores.PlaceholderCore +import com.lazy.emulate.emulation.cores.Ps1Core + +object EmulationEngine { + + private var activeCore: EmulatorCore? = null + + fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore { + return when (consoleType) { + ConsoleType.PS1 -> Ps1Core(context) + ConsoleType.NES -> PlaceholderCore(ConsoleType.NES) + ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES) + ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64) + ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS) + ConsoleType.PS2 -> PlaceholderCore(ConsoleType.PS2) + } + } + + fun startGame(context: Context, consoleType: ConsoleType, romPath: String): EmulatorCore? { + stopActiveGame() + val core = getCoreForConsole(context, consoleType) + if (!core.loadRom(romPath)) return null + activeCore = core + return core + } + + fun getActiveCore(): EmulatorCore? = activeCore + + fun stopActiveGame() { + activeCore?.stop() + activeCore = null + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt new file mode 100644 index 0000000..4c7d993 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt @@ -0,0 +1,39 @@ +package com.lazy.emulate.emulation + +import android.view.Surface +import com.lazy.emulate.input.GamepadButton + +interface EmulatorCore { + + val consoleType: ConsoleType + + val isRunning: Boolean + + fun loadRom(path: String): Boolean + + fun start() + + fun pause() + + fun resume() + + fun stop() + + fun reset() + + fun setSurface(surface: Surface) + + fun onButtonPressed(button: GamepadButton) + + fun onButtonReleased(button: GamepadButton) + + fun onAnalogStick(x: Float, y: Float, stickIndex: Int = 0) + + fun saveState(slot: Int): Boolean + + fun loadState(slot: Int): Boolean + + fun setAudioEnabled(enabled: Boolean) + + fun setFrameSkip(skip: Int) +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt new file mode 100644 index 0000000..10a20e0 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt @@ -0,0 +1,28 @@ +package com.lazy.emulate.emulation + +import android.view.Surface + +object NativeLibretro { + + init { + System.loadLibrary("emulate_native") + } + + external fun nativeLoadCore(corePath: String): Boolean + external fun nativeLoadGame(romPath: String): Boolean + external fun nativeStart() + external fun nativePause() + external fun nativeResume() + external fun nativeStop() + external fun nativeReset() + external fun nativeSetSurface(surface: Surface) + external fun nativeReleaseSurface() + external fun nativeSetButtonState(state: Int) + external fun nativeSetAnalogState(stick: Int, x: Short, y: Short) + external fun nativeSaveState(path: String): Boolean + external fun nativeLoadState(path: String): Boolean + external fun nativeSetAudioEnabled(enabled: Boolean) + external fun nativeSetFrameSkip(skip: Int) + external fun nativeSetSystemDir(dir: String) + external fun nativeSetSaveDir(dir: String) +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt new file mode 100644 index 0000000..7e1fa73 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt @@ -0,0 +1,43 @@ +package com.lazy.emulate.emulation.cores + +import android.util.Log +import android.view.Surface +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.emulation.EmulatorCore +import com.lazy.emulate.input.GamepadButton + +/** + * Placeholder core used for consoles that are not yet implemented. + * Returns reasonable defaults and logs calls for future implementation. + */ +class PlaceholderCore(override val consoleType: ConsoleType) : EmulatorCore { + + override var isRunning: Boolean = false + private set + + override fun loadRom(path: String): Boolean { + Log.w(TAG, "${consoleType.displayName} emulation is not yet implemented") + return false + } + + override fun start() { + Log.w(TAG, "${consoleType.displayName} emulation is not yet implemented") + } + + override fun pause() { isRunning = false } + override fun resume() { isRunning = true } + override fun stop() { isRunning = false } + override fun reset() {} + override fun setSurface(surface: Surface) {} + override fun onButtonPressed(button: GamepadButton) {} + override fun onButtonReleased(button: GamepadButton) {} + override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {} + override fun saveState(slot: Int): Boolean = false + override fun loadState(slot: Int): Boolean = false + override fun setAudioEnabled(enabled: Boolean) {} + override fun setFrameSkip(skip: Int) {} + + companion object { + private const val TAG = "PlaceholderCore" + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt new file mode 100644 index 0000000..f5a3287 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -0,0 +1,196 @@ +package com.lazy.emulate.emulation.cores + +import android.content.Context +import android.util.Log +import android.view.Surface +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.emulation.EmulatorCore +import com.lazy.emulate.emulation.NativeLibretro +import com.lazy.emulate.input.GamepadButton +import java.io.File + +class Ps1Core(private val context: Context) : EmulatorCore { + + override val consoleType = ConsoleType.PS1 + + override var isRunning: Boolean = false + private set + + private var buttonState: Int = 0 + + private val coresDir = File(context.filesDir, "cores") + private val systemDir = File(context.filesDir, "system") + private val savesDir = File(context.filesDir, "saves") + + override fun loadRom(path: String): Boolean { + coresDir.mkdirs() + systemDir.mkdirs() + savesDir.mkdirs() + + NativeLibretro.nativeSetSystemDir(systemDir.absolutePath) + NativeLibretro.nativeSetSaveDir(savesDir.absolutePath) + + // Look for core .so + val corePath = findCorePath() + if (corePath == null) { + Log.e(TAG, "PS1 core .so not found. Place pcsx_rearmed_libretro_android.so in ${coresDir.absolutePath}") + return false + } + + // Check BIOS (optional for some cores, but recommended) + val biosFile = File(systemDir, "scph1001.bin") + if (!biosFile.exists()) { + Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS") + } + + if (!NativeLibretro.nativeLoadCore(corePath)) { + Log.e(TAG, "Failed to load core: $corePath") + return false + } + + if (!NativeLibretro.nativeLoadGame(path)) { + Log.e(TAG, "Failed to load game: $path") + return false + } + + Log.i(TAG, "Game loaded successfully: $path") + return true + } + + override fun start() { + isRunning = true + NativeLibretro.nativeStart() + } + + override fun pause() { + isRunning = false + NativeLibretro.nativePause() + } + + override fun resume() { + isRunning = true + NativeLibretro.nativeResume() + } + + override fun stop() { + isRunning = false + NativeLibretro.nativeStop() + } + + override fun reset() { + NativeLibretro.nativeReset() + } + + override fun setSurface(surface: Surface) { + NativeLibretro.nativeSetSurface(surface) + } + + override fun onButtonPressed(button: GamepadButton) { + val bit = mapButtonToRetroId(button) + if (bit >= 0) { + buttonState = buttonState or (1 shl bit) + NativeLibretro.nativeSetButtonState(buttonState) + } + } + + override fun onButtonReleased(button: GamepadButton) { + val bit = mapButtonToRetroId(button) + if (bit >= 0) { + buttonState = buttonState and (1 shl bit).inv() + NativeLibretro.nativeSetButtonState(buttonState) + } + } + + override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) { + // Convert -1.0..1.0 float to -32768..32767 int16 range + val ix = (x * 32767).toInt().coerceIn(-32768, 32767).toShort() + val iy = (y * 32767).toInt().coerceIn(-32768, 32767).toShort() + NativeLibretro.nativeSetAnalogState(stickIndex, ix, iy) + } + + override fun saveState(slot: Int): Boolean { + val path = File(savesDir, "state_$slot.sav").absolutePath + return NativeLibretro.nativeSaveState(path) + } + + override fun loadState(slot: Int): Boolean { + val path = File(savesDir, "state_$slot.sav").absolutePath + return NativeLibretro.nativeLoadState(path) + } + + override fun setAudioEnabled(enabled: Boolean) { + NativeLibretro.nativeSetAudioEnabled(enabled) + } + + override fun setFrameSkip(skip: Int) { + NativeLibretro.nativeSetFrameSkip(skip) + } + + fun releaseSurface() { + NativeLibretro.nativeReleaseSurface() + } + + private fun findCorePath(): String? { + val names = listOf( + "pcsx_rearmed_libretro_android.so", + "pcsx_rearmed_libretro.so", + "mednafen_psx_libretro_android.so", + "mednafen_psx_libretro.so", + "swanstation_libretro.so", + ) + // Check internal cores dir first + for (name in names) { + val file = File(coresDir, name) + if (file.exists()) return file.absolutePath + } + // Auto-import from /sdcard/Games/cores/ into internal storage + // (dlopen requires the .so to be in app-writable storage, not sdcard) + val sdcardCores = File("/sdcard/Games/cores") + if (sdcardCores.exists()) { + for (name in names) { + val sdFile = File(sdcardCores, name) + if (sdFile.exists()) { + val dest = File(coresDir, name) + try { + sdFile.copyTo(dest, overwrite = true) + dest.setExecutable(true) + Log.i(TAG, "Imported core from sdcard: $name") + return dest.absolutePath + } catch (e: Exception) { + Log.e(TAG, "Failed to import core: ${e.message}") + } + } + } + } + return null + } + + private fun mapButtonToRetroId(button: GamepadButton): Int { + return when (button) { + // libretro joypad IDs: B=0 Y=1 SELECT=2 START=3 UP=4 DOWN=5 LEFT=6 RIGHT=7 + // A=8 X=9 L=10 R=11 L2=12 R2=13 L3=14 R3=15 + // PS1 mapping: Cross=A(8) Circle=B(0) Square=X(9) Triangle=Y(1) + GamepadButton.FACE_BOTTOM -> 8 // Cross -> A + GamepadButton.FACE_RIGHT -> 0 // Circle -> B + GamepadButton.FACE_LEFT -> 9 // Square -> X + GamepadButton.FACE_TOP -> 1 // Triangle -> Y + GamepadButton.DPAD_UP -> 4 + GamepadButton.DPAD_DOWN -> 5 + GamepadButton.DPAD_LEFT -> 6 + GamepadButton.DPAD_RIGHT -> 7 + GamepadButton.L1 -> 10 + GamepadButton.R1 -> 11 + GamepadButton.L2 -> 12 + GamepadButton.R2 -> 13 + GamepadButton.L3 -> 14 + GamepadButton.R3 -> 15 + GamepadButton.SELECT -> 2 + GamepadButton.START -> 3 + else -> -1 + } + } + + companion object { + private const val TAG = "Ps1Core" + } +} diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt b/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt new file mode 100644 index 0000000..55891d3 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt @@ -0,0 +1,113 @@ +package com.lazy.emulate.input + +import androidx.compose.ui.geometry.Offset +import com.lazy.emulate.emulation.ConsoleType + +data class ButtonPosition( + val button: GamepadButton, + val x: Float, // 0..1 normalized position + val y: Float, + val size: Float = 0.08f // normalized size relative to screen width +) + +data class ControllerLayout( + val consoleType: ConsoleType, + val name: String, + val buttons: List, + val dpadPosition: Offset = Offset(0.12f, 0.65f), + val dpadSize: Float = 0.18f, + val leftStickPosition: Offset? = null, + val rightStickPosition: Offset? = null, + val stickSize: Float = 0.14f, + val opacity: Float = 0.6f +) { + companion object { + fun defaultForConsole(consoleType: ConsoleType): ControllerLayout { + return when (consoleType) { + ConsoleType.PS1 -> ps1Default() + ConsoleType.NES -> nesDefault() + ConsoleType.SNES -> snesDefault() + ConsoleType.N64 -> n64Default() + ConsoleType.GENESIS -> genesisDefault() + ConsoleType.PS2 -> ps2Default() + } + } + + private fun ps1Default() = ControllerLayout( + consoleType = ConsoleType.PS1, + name = "Default", + buttons = listOf( + ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // Cross + ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // Circle + ButtonPosition(GamepadButton.FACE_LEFT, 0.78f, 0.62f), // Square + ButtonPosition(GamepadButton.FACE_TOP, 0.85f, 0.52f), // Triangle + ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f), + ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f), + ButtonPosition(GamepadButton.L2, 0.08f, 0.35f, 0.10f), + ButtonPosition(GamepadButton.R2, 0.85f, 0.35f, 0.10f), + ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f), + ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f), + ), + dpadPosition = Offset(0.12f, 0.62f), + leftStickPosition = Offset(0.25f, 0.80f), + rightStickPosition = Offset(0.72f, 0.80f), + ) + + private fun nesDefault() = ControllerLayout( + consoleType = ConsoleType.NES, + name = "Default", + buttons = listOf( + ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.70f), // B + ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // A + ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f), + ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f), + ), + dpadPosition = Offset(0.12f, 0.65f), + ) + + private fun snesDefault() = ControllerLayout( + consoleType = ConsoleType.SNES, + name = "Default", + buttons = listOf( + ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // B + ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // A + ButtonPosition(GamepadButton.FACE_LEFT, 0.78f, 0.62f), // Y + ButtonPosition(GamepadButton.FACE_TOP, 0.85f, 0.52f), // X + ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f), + ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f), + ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f), + ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f), + ), + dpadPosition = Offset(0.12f, 0.65f), + ) + + private fun n64Default() = ControllerLayout( + consoleType = ConsoleType.N64, + name = "Default", + buttons = listOf( + ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // A + ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // B + ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f), // L + ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f), // R + ButtonPosition(GamepadButton.L2, 0.25f, 0.50f, 0.10f), // Z + ButtonPosition(GamepadButton.START, 0.50f, 0.88f, 0.06f), + ), + dpadPosition = Offset(0.12f, 0.65f), + leftStickPosition = Offset(0.25f, 0.75f), + ) + + private fun genesisDefault() = ControllerLayout( + consoleType = ConsoleType.GENESIS, + name = "Default", + buttons = listOf( + ButtonPosition(GamepadButton.FACE_BOTTOM, 0.78f, 0.72f), // A + ButtonPosition(GamepadButton.FACE_RIGHT, 0.86f, 0.65f), // B + ButtonPosition(GamepadButton.FACE_LEFT, 0.94f, 0.58f), // C + ButtonPosition(GamepadButton.START, 0.50f, 0.88f, 0.06f), + ), + dpadPosition = Offset(0.12f, 0.65f), + ) + + private fun ps2Default() = ps1Default().copy(consoleType = ConsoleType.PS2, name = "Default") + } +} diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt new file mode 100644 index 0000000..cb4a2c9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt @@ -0,0 +1,148 @@ +package com.lazy.emulate.input + +import android.content.Context +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ControllerManager(context: Context) : InputManager.InputDeviceListener { + + private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager + + private val _connectedControllers = MutableStateFlow>(emptyList()) + val connectedControllers: StateFlow> = _connectedControllers.asStateFlow() + + private val _activeController = MutableStateFlow(null) + val activeController: StateFlow = _activeController.asStateFlow() + + var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null + var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null + + fun start() { + inputManager.registerInputDeviceListener(this, null) + refreshControllers() + } + + fun stop() { + inputManager.unregisterInputDeviceListener(this) + } + + private fun refreshControllers() { + val controllers = InputDevice.getDeviceIds() + .toList() + .mapNotNull { InputDevice.getDevice(it) } + .filter { isGameController(it) } + .map { device -> + GameController( + deviceId = device.id, + name = device.name, + vendorId = device.vendorId, + productId = device.productId, + isExternal = device.isExternal + ) + } + _connectedControllers.value = controllers + if (_activeController.value == null && controllers.isNotEmpty()) { + _activeController.value = controllers.first() + } + } + + fun handleKeyEvent(event: KeyEvent): Boolean { + if (!isGameControllerDevice(event.device ?: return false)) return false + + val button = mapKeyToButton(event.keyCode) ?: return false + when (event.action) { + KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true) + KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false) + } + return true + } + + fun handleMotionEvent(event: MotionEvent): Boolean { + if (!isGameControllerDevice(event.device ?: return false)) return false + + // Left stick + val lx = event.getAxisValue(MotionEvent.AXIS_X) + val ly = event.getAxisValue(MotionEvent.AXIS_Y) + if (lx != 0f || ly != 0f) { + onAnalogEvent?.invoke(lx, ly, 0) + } + + // Right stick + val rx = event.getAxisValue(MotionEvent.AXIS_Z) + val ry = event.getAxisValue(MotionEvent.AXIS_RZ) + if (rx != 0f || ry != 0f) { + onAnalogEvent?.invoke(rx, ry, 1) + } + + // D-pad via axes (some controllers report dpad as axis) + val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X) + val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + handleDpadAxis(hatX, hatY) + + return true + } + + private fun handleDpadAxis(hatX: Float, hatY: Float) { + onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f) + onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f) + onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f) + onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f) + } + + fun setActiveController(controller: GameController) { + _activeController.value = controller + } + + private fun mapKeyToButton(keyCode: Int): GamepadButton? { + return when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM + KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT + KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT + KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP + KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1 + KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1 + KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2 + KeyEvent.KEYCODE_BUTTON_R2 -> GamepadButton.R2 + KeyEvent.KEYCODE_BUTTON_THUMBL -> GamepadButton.L3 + KeyEvent.KEYCODE_BUTTON_THUMBR -> GamepadButton.R3 + KeyEvent.KEYCODE_BUTTON_START -> GamepadButton.START + KeyEvent.KEYCODE_BUTTON_SELECT -> GamepadButton.SELECT + KeyEvent.KEYCODE_DPAD_UP -> GamepadButton.DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> GamepadButton.DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> GamepadButton.DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> GamepadButton.DPAD_RIGHT + else -> null + } + } + + private fun isGameController(device: InputDevice): Boolean { + val sources = device.sources + return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || + (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + } + + private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device) + + // InputManager.InputDeviceListener + override fun onInputDeviceAdded(deviceId: Int) = refreshControllers() + override fun onInputDeviceRemoved(deviceId: Int) { + if (_activeController.value?.deviceId == deviceId) { + _activeController.value = null + } + refreshControllers() + } + override fun onInputDeviceChanged(deviceId: Int) = refreshControllers() +} + +data class GameController( + val deviceId: Int, + val name: String, + val vendorId: Int, + val productId: Int, + val isExternal: Boolean +) diff --git a/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt b/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt new file mode 100644 index 0000000..5fbf494 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt @@ -0,0 +1,78 @@ +package com.lazy.emulate.input + +enum class GamepadButton { + // D-Pad + DPAD_UP, + DPAD_DOWN, + DPAD_LEFT, + DPAD_RIGHT, + + // Face buttons (universal naming, mapped per console) + FACE_BOTTOM, // PS: Cross, Nintendo: B, Genesis: A + FACE_RIGHT, // PS: Circle, Nintendo: A, Genesis: B + FACE_LEFT, // PS: Square, Nintendo: Y, Genesis: X + FACE_TOP, // PS: Triangle, Nintendo: X, Genesis: Y + + // Shoulders + L1, + R1, + L2, + R2, + + // Sticks + L3, + R3, + + // Meta + START, + SELECT, + + // Genesis-specific + MODE, // Genesis mode button + Z_BUTTON, // Genesis 6-button Z + C_BUTTON; // Genesis 6-button C + + companion object { + val PS1_BUTTONS = listOf( + DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, + FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP, + L1, R1, L2, R2, L3, R3, START, SELECT + ) + + val NES_BUTTONS = listOf( + DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, + FACE_BOTTOM, FACE_RIGHT, START, SELECT + ) + + val SNES_BUTTONS = listOf( + DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, + FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP, + L1, R1, START, SELECT + ) + + val N64_BUTTONS = listOf( + DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, + FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP, + L1, R1, L2, START + ) + + val GENESIS_BUTTONS = listOf( + DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT, + FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, + START, MODE, Z_BUTTON, C_BUTTON + ) + + val PS2_BUTTONS = PS1_BUTTONS + + fun buttonsForConsole(consoleType: com.lazy.emulate.emulation.ConsoleType): List { + return when (consoleType) { + com.lazy.emulate.emulation.ConsoleType.PS1 -> PS1_BUTTONS + com.lazy.emulate.emulation.ConsoleType.NES -> NES_BUTTONS + com.lazy.emulate.emulation.ConsoleType.SNES -> SNES_BUTTONS + com.lazy.emulate.emulation.ConsoleType.N64 -> N64_BUTTONS + com.lazy.emulate.emulation.ConsoleType.GENESIS -> GENESIS_BUTTONS + com.lazy.emulate.emulation.ConsoleType.PS2 -> PS2_BUTTONS + } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt b/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt new file mode 100644 index 0000000..64ba7e3 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt @@ -0,0 +1,25 @@ +package com.lazy.emulate.ui.adaptive + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass + +enum class LayoutType { + COMPACT, // Phone portrait + MEDIUM, // Foldable inner / small tablet + EXPANDED // Tablet landscape +} + +fun WindowSizeClass.toLayoutType(): LayoutType { + return when (widthSizeClass) { + WindowWidthSizeClass.Compact -> LayoutType.COMPACT + WindowWidthSizeClass.Medium -> LayoutType.MEDIUM + WindowWidthSizeClass.Expanded -> LayoutType.EXPANDED + else -> LayoutType.COMPACT + } +} + +fun LayoutType.gridColumns(): Int = when (this) { + LayoutType.COMPACT -> 2 + LayoutType.MEDIUM -> 3 + LayoutType.EXPANDED -> 4 +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt b/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt new file mode 100644 index 0000000..3d727cf --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt @@ -0,0 +1,41 @@ +package com.lazy.emulate.ui.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lazy.emulate.emulation.ConsoleType + +@Composable +fun ConsoleFilter( + selectedConsole: ConsoleType?, + onConsoleSelected: (ConsoleType?) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedConsole == null, + onClick = { onConsoleSelected(null) }, + label = { Text("All") } + ) + ConsoleType.entries.forEach { console -> + FilterChip( + selected = selectedConsole == console, + onClick = { onConsoleSelected(console) }, + label = { Text(console.displayName) }, + enabled = console.isImplemented + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt new file mode 100644 index 0000000..534739f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt @@ -0,0 +1,104 @@ +package com.lazy.emulate.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lazy.emulate.data.model.Game + +@Composable +fun GameCard( + game: Game, + onClick: () -> Unit, + onFavoriteToggle: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column { + // Cover art placeholder + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SportsEsports, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.padding(32.dp) + ) + + // Console badge + Text( + text = game.consoleType.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + // Title and favorite + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = game.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(end = 36.dp) + ) + IconButton( + onClick = onFavoriteToggle, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon( + imageVector = if (game.favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = if (game.favorite) "Remove from favorites" else "Add to favorites", + tint = if (game.favorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt b/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt new file mode 100644 index 0000000..8795798 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt @@ -0,0 +1,286 @@ +package com.lazy.emulate.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.lazy.emulate.input.ButtonPosition +import com.lazy.emulate.input.ControllerLayout +import com.lazy.emulate.input.GamepadButton +import kotlin.math.atan2 +import kotlin.math.sqrt + +@Composable +fun TouchControllerOverlay( + layout: ControllerLayout, + onButtonPressed: (GamepadButton) -> Unit, + onButtonReleased: (GamepadButton) -> Unit, + onAnalogStick: (Float, Float, Int) -> Unit, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val screenWidth = constraints.maxWidth.toFloat() + val screenHeight = constraints.maxHeight.toFloat() + + // D-Pad + DPad( + centerX = layout.dpadPosition.x * screenWidth, + centerY = layout.dpadPosition.y * screenHeight, + size = layout.dpadSize * screenWidth, + opacity = layout.opacity, + onDirectionPressed = { onButtonPressed(it) }, + onDirectionReleased = { onButtonReleased(it) } + ) + + // Analog sticks + layout.leftStickPosition?.let { pos -> + AnalogStick( + centerX = pos.x * screenWidth, + centerY = pos.y * screenHeight, + size = layout.stickSize * screenWidth, + opacity = layout.opacity, + onStickMoved = { x, y -> onAnalogStick(x, y, 0) } + ) + } + layout.rightStickPosition?.let { pos -> + AnalogStick( + centerX = pos.x * screenWidth, + centerY = pos.y * screenHeight, + size = layout.stickSize * screenWidth, + opacity = layout.opacity, + onStickMoved = { x, y -> onAnalogStick(x, y, 1) } + ) + } + + // Face buttons and shoulders + layout.buttons.forEach { buttonPos -> + TouchButton( + buttonPosition = buttonPos, + screenWidth = screenWidth, + screenHeight = screenHeight, + opacity = layout.opacity, + onPressed = { onButtonPressed(buttonPos.button) }, + onReleased = { onButtonReleased(buttonPos.button) } + ) + } + } +} + +@Composable +private fun TouchButton( + buttonPosition: ButtonPosition, + screenWidth: Float, + screenHeight: Float, + opacity: Float, + onPressed: () -> Unit, + onReleased: () -> Unit +) { + val sizePx = buttonPosition.size * screenWidth + val density = LocalDensity.current + + val sizeDp = with(density) { sizePx.toDp() } + val xDp = with(density) { (buttonPosition.x * screenWidth - sizePx / 2).toDp() } + val yDp = with(density) { (buttonPosition.y * screenHeight - sizePx / 2).toDp() } + + var isPressed by remember { mutableStateOf(false) } + val color = if (isPressed) Color.White.copy(alpha = opacity * 0.8f) else Color.White.copy(alpha = opacity * 0.4f) + + Box( + modifier = Modifier + .offset(x = xDp, y = yDp) + .size(sizeDp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val wasPressed = isPressed + isPressed = event.changes.any { it.pressed } + if (isPressed && !wasPressed) onPressed() + if (!isPressed && wasPressed) onReleased() + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = color, + radius = size.minDimension / 2, + style = Stroke(width = 3.dp.toPx()) + ) + if (isPressed) { + drawCircle( + color = color.copy(alpha = color.alpha * 0.3f), + radius = size.minDimension / 2 + ) + } + } + } +} + +@Composable +private fun DPad( + centerX: Float, + centerY: Float, + size: Float, + opacity: Float, + onDirectionPressed: (GamepadButton) -> Unit, + onDirectionReleased: (GamepadButton) -> Unit +) { + val density = LocalDensity.current + val sizeDp = with(density) { size.toDp() } + val xDp = with(density) { (centerX - size / 2).toDp() } + val yDp = with(density) { (centerY - size / 2).toDp() } + + // Track which directions are currently held + var activeDirections by remember { mutableStateOf(emptySet()) } + val color = Color.White.copy(alpha = opacity * 0.4f) + + Box( + modifier = Modifier + .offset(x = xDp, y = yDp) + .size(sizeDp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + change.consume() + + val newDirections = mutableSetOf() + + if (change.pressed) { + val dx = change.position.x - this@pointerInput.size.width / 2f + val dy = change.position.y - this@pointerInput.size.height / 2f + val dist = sqrt(dx * dx + dy * dy) + val deadZone = this@pointerInput.size.width * 0.12f + + if (dist > deadZone) { + // Use thresholds instead of exclusive angles to allow diagonals + val nx = dx / dist + val ny = dy / dist + val threshold = 0.38f // ~22 degrees — allows diagonal input + + if (nx > threshold) newDirections.add(GamepadButton.DPAD_RIGHT) + if (nx < -threshold) newDirections.add(GamepadButton.DPAD_LEFT) + if (ny > threshold) newDirections.add(GamepadButton.DPAD_DOWN) + if (ny < -threshold) newDirections.add(GamepadButton.DPAD_UP) + } + } + + // Release directions no longer held + for (dir in activeDirections - newDirections) { + onDirectionReleased(dir) + } + // Press newly held directions + for (dir in newDirections - activeDirections) { + onDirectionPressed(dir) + } + activeDirections = newDirections + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val armWidth = size * 0.3f + val center = Offset(size / 2, size / 2) + + // Horizontal arm + drawRoundRect( + color = if (GamepadButton.DPAD_LEFT in activeDirections || GamepadButton.DPAD_RIGHT in activeDirections) + color.copy(alpha = color.alpha * 1.5f) else color, + topLeft = Offset(0f, center.y - armWidth / 2), + size = androidx.compose.ui.geometry.Size(size, armWidth), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f, 8f), + style = Stroke(width = 2.dp.toPx()) + ) + // Vertical arm + drawRoundRect( + color = if (GamepadButton.DPAD_UP in activeDirections || GamepadButton.DPAD_DOWN in activeDirections) + color.copy(alpha = color.alpha * 1.5f) else color, + topLeft = Offset(center.x - armWidth / 2, 0f), + size = androidx.compose.ui.geometry.Size(armWidth, size), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f, 8f), + style = Stroke(width = 2.dp.toPx()) + ) + } + } +} + +@Composable +private fun AnalogStick( + centerX: Float, + centerY: Float, + size: Float, + opacity: Float, + onStickMoved: (Float, Float) -> Unit +) { + val density = LocalDensity.current + val sizeDp = with(density) { size.toDp() } + val xDp = with(density) { (centerX - size / 2).toDp() } + val yDp = with(density) { (centerY - size / 2).toDp() } + + var stickOffset by remember { mutableStateOf(Offset.Zero) } + val maxRadius = size / 2 * 0.7f + val color = Color.White.copy(alpha = opacity * 0.4f) + + Box( + modifier = Modifier + .offset(x = xDp, y = yDp) + .size(sizeDp) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { + stickOffset = Offset.Zero + onStickMoved(0f, 0f) + }, + onDragCancel = { + stickOffset = Offset.Zero + onStickMoved(0f, 0f) + } + ) { _, dragAmount -> + val newOffset = stickOffset + dragAmount + val dist = sqrt(newOffset.x * newOffset.x + newOffset.y * newOffset.y) + stickOffset = if (dist > maxRadius) { + newOffset * (maxRadius / dist) + } else { + newOffset + } + onStickMoved(stickOffset.x / maxRadius, stickOffset.y / maxRadius) + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val center = Offset(size / 2, size / 2) + // Outer ring + drawCircle( + color = color, + radius = size / 2 - 4, + center = center, + style = Stroke(width = 2.dp.toPx()) + ) + // Inner stick + drawCircle( + color = color.copy(alpha = color.alpha * 1.5f), + radius = size * 0.15f, + center = center + stickOffset + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..7fee3c4 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt @@ -0,0 +1,77 @@ +package com.lazy.emulate.ui.navigation + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.lazy.emulate.data.repository.GameRepository +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen +import com.lazy.emulate.ui.screens.game.GameScreen +import com.lazy.emulate.ui.screens.home.HomeScreen +import com.lazy.emulate.ui.screens.settings.SettingsScreen + +@Composable +fun EmulateNavGraph( + navController: NavHostController, + windowSizeClass: WindowSizeClass, + gameRepository: GameRepository, + controllerManager: ControllerManager +) { + NavHost( + navController = navController, + startDestination = Screen.Home.route + ) { + composable(Screen.Home.route) { + HomeScreen( + windowSizeClass = windowSizeClass, + gameRepository = gameRepository, + onGameSelected = { game -> + navController.navigate(Screen.Game.createRoute(game.id)) + }, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, + onControllerLayoutClick = { consoleType -> + navController.navigate(Screen.ControllerLayout.createRoute(consoleType.name)) + } + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + controllerManager = controllerManager, + onBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.ControllerLayout.route, + arguments = listOf(navArgument("consoleType") { type = NavType.StringType }) + ) { backStackEntry -> + val consoleTypeName = backStackEntry.arguments?.getString("consoleType") ?: return@composable + val consoleType = ConsoleType.valueOf(consoleTypeName) + ControllerLayoutScreen( + consoleType = consoleType, + onBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.Game.route, + arguments = listOf(navArgument("gameId") { type = NavType.StringType }) + ) { backStackEntry -> + val gameId = backStackEntry.arguments?.getString("gameId") ?: return@composable + val game = gameRepository.games.value.find { it.id == gameId } ?: return@composable + GameScreen( + game = game, + controllerManager = controllerManager, + onBack = { navController.popBackStack() } + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt new file mode 100644 index 0000000..15fbc3f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt @@ -0,0 +1,12 @@ +package com.lazy.emulate.ui.navigation + +sealed class Screen(val route: String) { + data object Home : Screen("home") + data object Settings : Screen("settings") + data object ControllerLayout : Screen("controller_layout/{consoleType}") { + fun createRoute(consoleType: String) = "controller_layout/$consoleType" + } + data object Game : Screen("game/{gameId}") { + fun createRoute(gameId: String) = "game/$gameId" + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt new file mode 100644 index 0000000..9c444ed --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt @@ -0,0 +1,228 @@ +package com.lazy.emulate.ui.screens.controller + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.input.ButtonPosition +import com.lazy.emulate.input.ControllerLayout +import com.lazy.emulate.input.GamepadButton +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControllerLayoutScreen( + consoleType: ConsoleType, + onBack: () -> Unit +) { + var layout by remember { mutableStateOf(ControllerLayout.defaultForConsole(consoleType)) } + var opacity by remember { mutableFloatStateOf(layout.opacity) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("${consoleType.displayName} Layout") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { + layout = ControllerLayout.defaultForConsole(consoleType) + opacity = layout.opacity + }) { + Icon(Icons.Default.RestartAlt, contentDescription = "Reset Layout") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Preview area + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(Color.Black) + ) { + val screenWidth = constraints.maxWidth.toFloat() + val screenHeight = constraints.maxHeight.toFloat() + val density = LocalDensity.current + + // Draggable buttons + layout.buttons.forEach { buttonPos -> + DraggableButton( + buttonPosition = buttonPos, + screenWidth = screenWidth, + screenHeight = screenHeight, + opacity = opacity, + onPositionChanged = { newX, newY -> + layout = layout.copy( + buttons = layout.buttons.map { + if (it.button == buttonPos.button) { + it.copy(x = newX / screenWidth, y = newY / screenHeight) + } else it + } + ) + } + ) + } + + // Info overlay + Text( + text = "Drag buttons to reposition", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.5f), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(16.dp) + ) + } + + // Controls + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text("Opacity", style = MaterialTheme.typography.bodyLarge) + Slider( + value = opacity, + onValueChange = { + opacity = it + layout = layout.copy(opacity = it) + }, + valueRange = 0.1f..1.0f + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row { + OutlinedButton( + onClick = { + layout = ControllerLayout.defaultForConsole(consoleType) + opacity = layout.opacity + }, + modifier = Modifier.weight(1f) + ) { + Text("Reset") + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = { + // TODO: Save layout to DataStore + onBack() + }, + modifier = Modifier.weight(1f) + ) { + Text("Save") + } + } + } + } + } +} + +@Composable +private fun DraggableButton( + buttonPosition: ButtonPosition, + screenWidth: Float, + screenHeight: Float, + opacity: Float, + onPositionChanged: (Float, Float) -> Unit +) { + val density = LocalDensity.current + val sizePx = buttonPosition.size * screenWidth + var offsetX by remember { mutableFloatStateOf(buttonPosition.x * screenWidth) } + var offsetY by remember { mutableFloatStateOf(buttonPosition.y * screenHeight) } + + val sizeDp = with(density) { sizePx.toDp() } + + Box( + modifier = Modifier + .offset { IntOffset((offsetX - sizePx / 2).roundToInt(), (offsetY - sizePx / 2).roundToInt()) } + .size(sizeDp) + .border(2.dp, Color.White.copy(alpha = opacity), CircleShape) + .background(Color.White.copy(alpha = opacity * 0.2f), CircleShape) + .pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + offsetX = (offsetX + dragAmount.x).coerceIn(0f, screenWidth) + offsetY = (offsetY + dragAmount.y).coerceIn(0f, screenHeight) + onPositionChanged(offsetX, offsetY) + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = buttonPosition.button.toLabel(), + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = opacity) + ) + } +} + +private fun GamepadButton.toLabel(): String = when (this) { + GamepadButton.FACE_BOTTOM -> "X" + GamepadButton.FACE_RIGHT -> "O" + GamepadButton.FACE_LEFT -> "[]" + GamepadButton.FACE_TOP -> "/\\" + GamepadButton.DPAD_UP -> "Up" + GamepadButton.DPAD_DOWN -> "Dn" + GamepadButton.DPAD_LEFT -> "Lt" + GamepadButton.DPAD_RIGHT -> "Rt" + GamepadButton.L1 -> "L1" + GamepadButton.R1 -> "R1" + GamepadButton.L2 -> "L2" + GamepadButton.R2 -> "R2" + GamepadButton.L3 -> "L3" + GamepadButton.R3 -> "R3" + GamepadButton.START -> "ST" + GamepadButton.SELECT -> "SL" + GamepadButton.MODE -> "MD" + GamepadButton.Z_BUTTON -> "Z" + GamepadButton.C_BUTTON -> "C" +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt new file mode 100644 index 0000000..6e6924b --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -0,0 +1,181 @@ +package com.lazy.emulate.ui.screens.game + +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.lazy.emulate.data.model.Game +import com.lazy.emulate.emulation.EmulationEngine +import com.lazy.emulate.emulation.EmulatorCore +import com.lazy.emulate.emulation.cores.Ps1Core +import com.lazy.emulate.input.ControllerLayout +import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.ui.components.TouchControllerOverlay + +@Composable +fun GameScreen( + game: Game, + controllerManager: ControllerManager, + onBack: () -> Unit +) { + val context = LocalContext.current + var core by remember { mutableStateOf(null) } + var isPaused by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } + val activeController by controllerManager.activeController.collectAsState() + val showTouchControls = activeController == null + + val layout = remember(game.consoleType) { + ControllerLayout.defaultForConsole(game.consoleType) + } + + DisposableEffect(game.id) { + val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath) + if (loaded != null) { + core = loaded + controllerManager.onButtonEvent = { button, pressed -> + if (pressed) loaded.onButtonPressed(button) else loaded.onButtonReleased(button) + } + controllerManager.onAnalogEvent = { x, y, stick -> + loaded.onAnalogStick(x, y, stick) + } + } else { + loadError = "Failed to load game. Check that the core .so and BIOS are installed." + } + onDispose { + (core as? Ps1Core)?.releaseSurface() + EmulationEngine.stopActiveGame() + controllerManager.onButtonEvent = null + controllerManager.onAnalogEvent = null + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Emulation surface + if (core != null) { + AndroidView( + factory = { ctx -> + SurfaceView(ctx).apply { + holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + core?.setSurface(holder.surface) + core?.start() + } + + override fun surfaceChanged( + holder: SurfaceHolder, format: Int, width: Int, height: Int + ) { + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + core?.pause() + (core as? Ps1Core)?.releaseSurface() + } + }) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + // Error or placeholder message + val msg = loadError + ?: if (core == null || !game.consoleType.isImplemented) + "${game.consoleType.displayName} emulation coming soon" + else null + + if (msg != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = msg, + style = MaterialTheme.typography.headlineMedium, + color = Color.White.copy(alpha = 0.7f) + ) + } + } + + // Touch controls (hidden when BT controller connected) + if (showTouchControls && core != null) { + TouchControllerOverlay( + layout = layout, + onButtonPressed = { core?.onButtonPressed(it) }, + onButtonReleased = { core?.onButtonReleased(it) }, + onAnalogStick = { x, y, stick -> core?.onAnalogStick(x, y, stick) } + ) + } + + // HUD controls + IconButton( + onClick = onBack, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = { + isPaused = !isPaused + if (isPaused) core?.pause() else core?.resume() + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = if (isPaused) "Resume" else "Pause", + tint = Color.White.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = { core?.saveState(0) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 48.dp) + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save State", + tint = Color.White.copy(alpha = 0.7f) + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..77e6025 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt @@ -0,0 +1,164 @@ +package com.lazy.emulate.ui.screens.home + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.unit.dp +import com.lazy.emulate.data.model.Game +import com.lazy.emulate.data.repository.GameRepository +import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.ui.adaptive.gridColumns +import com.lazy.emulate.ui.adaptive.toLayoutType +import com.lazy.emulate.ui.components.ConsoleFilter +import com.lazy.emulate.ui.components.GameCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + windowSizeClass: WindowSizeClass, + gameRepository: GameRepository, + onGameSelected: (Game) -> Unit, + onSettingsClick: () -> Unit, + onControllerLayoutClick: (ConsoleType) -> Unit +) { + val games by gameRepository.games.collectAsState() + var selectedConsole by remember { mutableStateOf(null) } + val layoutType = windowSizeClass.toLayoutType() + + val filteredGames = if (selectedConsole != null) { + games.filter { it.consoleType == selectedConsole } + } else { + games + } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { + // Default to PS1 for now, could show a picker dialog + gameRepository.addGameFromUri(it, ConsoleType.PS1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Emulate") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ), + actions = { + IconButton(onClick = { onControllerLayoutClick(selectedConsole ?: ConsoleType.PS1) }) { + Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout") + } + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + filePickerLauncher.launch(arrayOf("*/*")) + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Game") + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + ConsoleFilter( + selectedConsole = selectedConsole, + onConsoleSelected = { selectedConsole = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (filteredGames.isEmpty()) { + EmptyLibrary() + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(layoutType.gridColumns()), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredGames, key = { it.id }) { game -> + GameCard( + game = game, + onClick = { onGameSelected(game) }, + onFavoriteToggle = { gameRepository.toggleFavorite(game.id) } + ) + } + } + } + } + } +} + +@Composable +private fun EmptyLibrary() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.SportsEsports, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.padding(16.dp) + ) + Text( + text = "No games in library", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap + to add your backup ROMs", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..f9346aa --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,195 @@ +package com.lazy.emulate.ui.screens.settings + +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.height +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.Bluetooth +import androidx.compose.material.icons.filled.BluetoothConnected +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material3.Card +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.input.GameController + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + controllerManager: ControllerManager, + onBack: () -> Unit +) { + val controllers by controllerManager.connectedControllers.collectAsState() + val activeController by controllerManager.activeController.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Controllers section + Text( + text = "Controllers", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (controllers.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Bluetooth, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "No controllers connected", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Pair a Bluetooth controller in system settings", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + Card(modifier = Modifier.fillMaxWidth()) { + controllers.forEachIndexed { index, controller -> + ControllerItem( + controller = controller, + isActive = controller.deviceId == activeController?.deviceId, + onClick = { controllerManager.setActiveController(controller) } + ) + if (index < controllers.lastIndex) { + HorizontalDivider() + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Emulation section + Text( + text = "Emulation", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Audio", style = MaterialTheme.typography.bodyLarge) + Text( + "Enabled", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + HorizontalDivider() + Column(modifier = Modifier.padding(16.dp)) { + Text("Frame Skip", style = MaterialTheme.typography.bodyLarge) + Text( + "Auto", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // About + Text( + text = "About", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Emulate v1.0", style = MaterialTheme.typography.bodyLarge) + Text( + "For personal backup games only", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun ControllerItem( + controller: GameController, + isActive: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isActive) Icons.Default.BluetoothConnected else Icons.Default.Gamepad, + contentDescription = null, + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = controller.name, + style = MaterialTheme.typography.bodyLarge, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (isActive) "Active" else if (controller.isExternal) "Bluetooth" else "Built-in", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt new file mode 100644 index 0000000..e412abf --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package com.lazy.emulate.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFBB86FC) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6200EE) +val PurpleGrey40 = Color(0xFF625B71) +val Pink40 = Color(0xFF7D5260) + +// Console brand colors +val NintendoRed = Color(0xFFE60012) +val PlayStationBlue = Color(0xFF003087) +val SegaBlue = Color(0xFF0060A8) + +val SurfaceDark = Color(0xFF121212) +val SurfaceVariantDark = Color(0xFF1E1E2E) +val CardDark = Color(0xFF2A2A3C) diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt new file mode 100644 index 0000000..9f59eba --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt @@ -0,0 +1,47 @@ +package com.lazy.emulate.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + surface = SurfaceDark, + surfaceVariant = SurfaceVariantDark, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, +) + +@Composable +fun EmulateTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt new file mode 100644 index 0000000..d5528b9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt @@ -0,0 +1,52 @@ +package com.lazy.emulate.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..94369a7 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8692ae0 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Emulate + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1cff752 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt b/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt new file mode 100644 index 0000000..db2a893 --- /dev/null +++ b/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.lazy.emulate + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8dacf8c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4a32fc4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,53 @@ +[versions] +agp = "9.0.1" +kotlin = "2.1.20" +coreKtx = "1.16.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +composeBom = "2025.04.01" +activityCompose = "1.10.1" +navigationCompose = "2.9.0" +lifecycle = "2.9.0" +coroutines = "1.10.2" +windowSizeClass = "1.3.2" +datastorePrefs = "1.1.4" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +# Compose +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Lifecycle +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +# Window Size +material3-wsc = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "windowSizeClass" } + +# Coroutines +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +# DataStore +datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePrefs" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a47f7f6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Wed Apr 08 19:39:50 EDT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..adc56d5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Emulate" +include(":app")