From 7368726c510923cb702939432b92b77b811a4cdb Mon Sep 17 00:00:00 2001 From: Luckas Date: Fri, 29 Nov 2024 17:57:54 +0300 Subject: [PATCH] feat: android port (#4) * wip: initial app * update: gitignore * feat: implement audio playback * update: refactor & UI improvements * update: add back handler & button effects * refactor: remove trailing new lines * feat: menu, settings & UI updates * update: improve UI * wip: metadata * update: improve ui * refactor: json as feature * fix: compilation * update: refactor, floating settings * update: ui, themes * fix: libs * feat: shortcut * feat: push reload, tags * refactor: cleanup * feat: custom palette * wip: info screen * feat: open source license * fix: license * update: README.md --- Cargo.lock | 48 +++++ README.md | 72 ++++++- android/.gitignore | 17 ++ android/.idea/.gitignore | 3 + android/.idea/.name | 1 + android/.idea/appInsightsSettings.xml | 26 +++ android/.idea/codeStyles/Project.xml | 123 +++++++++++ android/.idea/codeStyles/codeStyleConfig.xml | 5 + android/.idea/compiler.xml | 6 + android/.idea/deploymentTargetSelector.xml | 18 ++ android/.idea/gradle.xml | 20 ++ .../inspectionProfiles/Project_Default.xml | 57 +++++ android/.idea/kotlinc.xml | 6 + android/.idea/migrations.xml | 10 + android/.idea/misc.xml | 9 + android/.idea/runConfigurations.xml | 17 ++ android/.idea/vcs.xml | 6 + android/app/.gitignore | 1 + android/app/build.gradle.kts | 116 ++++++++++ android/app/proguard-rules.pro | 21 ++ .../mes/ExampleInstrumentedTest.kt | 24 +++ android/app/src/main/AndroidManifest.xml | 31 +++ .../app/src/main/ic_launcher-playstore.png | Bin 0 -> 5840 bytes .../dev/luckasranarison/mes/Application.kt | 58 +++++ .../dev/luckasranarison/mes/MainActivity.kt | 61 ++++++ .../dev/luckasranarison/mes/anim/Animation.kt | 37 ++++ .../mes/data/SettingsRepository.kt | 37 ++++ .../dev/luckasranarison/mes/data/Store.kt | 6 + .../dev/luckasranarison/mes/data/Types.kt | 43 ++++ .../dev/luckasranarison/mes/extra/Shortcut.kt | 23 ++ .../java/dev/luckasranarison/mes/lib/Audio.kt | 28 +++ .../dev/luckasranarison/mes/lib/Controller.kt | 17 ++ .../java/dev/luckasranarison/mes/lib/Nes.kt | 71 ++++++ .../java/dev/luckasranarison/mes/lib/Rust.kt | 5 + .../mes/ui/emulator/EmulatorBackHandler.kt | 52 +++++ .../mes/ui/emulator/EmulatorScreen.kt | 51 +++++ .../mes/ui/emulator/EmulatorView.kt | 39 ++++ .../mes/ui/emulator/FullScreenContainer.kt | 45 ++++ .../mes/ui/gamepad/ActionButton.kt | 40 ++++ .../mes/ui/gamepad/BaseButton.kt | 46 ++++ .../mes/ui/gamepad/DirectionButton.kt | 79 +++++++ .../mes/ui/gamepad/GamePadLayout.kt | 69 ++++++ .../mes/ui/gamepad/MenuButton.kt | 37 ++++ .../mes/ui/home/DirectoryChooser.kt | 28 +++ .../mes/ui/home/FloatingButton.kt | 38 ++++ .../luckasranarison/mes/ui/home/HomeScreen.kt | 93 ++++++++ .../luckasranarison/mes/ui/home/TopAppBar.kt | 38 ++++ .../luckasranarison/mes/ui/info/AppIcon.kt | 27 +++ .../luckasranarison/mes/ui/info/InfoScreen.kt | 64 ++++++ .../mes/ui/info/InfoSection.kt | 30 +++ .../dev/luckasranarison/mes/ui/info/Utils.kt | 10 + .../mes/ui/license/BottomSheet.kt | 76 +++++++ .../mes/ui/license/LibraryContainer.kt | 50 +++++ .../luckasranarison/mes/ui/license/License.kt | 40 ++++ .../luckasranarison/mes/ui/rom/InitialBox.kt | 41 ++++ .../mes/ui/rom/RomContainer.kt | 72 +++++++ .../dev/luckasranarison/mes/ui/rom/RomList.kt | 60 ++++++ .../mes/ui/rom/sheet/BottomSheet.kt | 55 +++++ .../mes/ui/rom/sheet/Metadata.kt | 55 +++++ .../mes/ui/rom/sheet/TopRow.kt | 52 +++++ .../mes/ui/settings/FloatingSettings.kt | 39 ++++ .../mes/ui/settings/SettingsScreen.kt | 96 +++++++++ .../mes/ui/settings/SettingsSection.kt | 81 +++++++ .../luckasranarison/mes/ui/settings/Utils.kt | 11 + .../mes/ui/shared/TopAppBar.kt | 29 +++ .../dev/luckasranarison/mes/ui/theme/Color.kt | 11 + .../dev/luckasranarison/mes/ui/theme/Theme.kt | 48 +++++ .../dev/luckasranarison/mes/ui/theme/Type.kt | 17 ++ .../dev/luckasranarison/mes/vm/ViewModel.kt | 202 ++++++++++++++++++ android/app/src/main/jniLibs/arm64-v8a/.keep | 0 android/app/src/main/jniLibs/x86_64/.keep | 0 .../src/main/res/drawable/app_shortcut.xml | 5 + .../res/drawable/ic_launcher_background.xml | 170 +++++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 15 ++ .../app/src/main/res/drawable/nes_icon.xml | 10 + .../app/src/main/res/drawable/web_search.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 738 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2120 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 588 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1450 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 960 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2972 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1398 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4748 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 1810 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6468 bytes android/app/src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 5 + android/app/src/main/res/xml/backup_rules.xml | 13 ++ .../main/res/xml/data_extraction_rules.xml | 19 ++ .../luckasranarison/mes/ExampleUnitTest.kt | 17 ++ android/build.gradle.kts | 7 + android/gradle.properties | 23 ++ android/gradle/libs.versions.toml | 47 ++++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + android/gradlew | 185 ++++++++++++++++ android/gradlew.bat | 89 ++++++++ android/settings.gradle.kts | 23 ++ crates/mes-core/Cargo.toml | 8 + crates/mes-core/src/cartridge/mod.rs | 32 ++- crates/mes-core/src/cpu/register.rs | 5 + crates/mes-core/src/features/json.rs | 7 + crates/mes-core/src/features/mod.rs | 2 + crates/mes-core/src/lib.rs | 21 +- crates/mes-core/src/mappers/mod.rs | 26 ++- crates/mes-core/src/utils/mod.rs | 4 + crates/mes-jni/Cargo.toml | 2 +- crates/mes-jni/src/lib.rs | 79 ++++++- 113 files changed, 3673 insertions(+), 23 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/.idea/.gitignore create mode 100644 android/.idea/.name create mode 100644 android/.idea/appInsightsSettings.xml create mode 100644 android/.idea/codeStyles/Project.xml create mode 100644 android/.idea/codeStyles/codeStyleConfig.xml create mode 100644 android/.idea/compiler.xml create mode 100644 android/.idea/deploymentTargetSelector.xml create mode 100644 android/.idea/gradle.xml create mode 100644 android/.idea/inspectionProfiles/Project_Default.xml create mode 100644 android/.idea/kotlinc.xml create mode 100644 android/.idea/migrations.xml create mode 100644 android/.idea/misc.xml create mode 100644 android/.idea/runConfigurations.xml create mode 100644 android/.idea/vcs.xml create mode 100644 android/app/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/ic_launcher-playstore.png create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/Application.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt create mode 100644 android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt create mode 100644 android/app/src/main/jniLibs/arm64-v8a/.keep create mode 100644 android/app/src/main/jniLibs/x86_64/.keep create mode 100644 android/app/src/main/res/drawable/app_shortcut.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/nes_icon.xml create mode 100644 android/app/src/main/res/drawable/web_search.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/test/java/dev/luckasranarison/mes/ExampleUnitTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 crates/mes-core/src/features/json.rs create mode 100644 crates/mes-core/src/features/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 173f75c..b91b676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "itoa" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" + [[package]] name = "jni" version = "0.21.1" @@ -82,6 +88,10 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mes-core" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "mes-jni" @@ -124,6 +134,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "same-file" version = "1.0.6" @@ -133,6 +149,38 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "syn" version = "2.0.87" diff --git a/README.md b/README.md index f9c901f..288ef39 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,24 @@ # Mes -A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now](https://luckasranarison.github.io/mes/). +A decent multiplatform NES emulator built using Rust. Try it [now](https://luckasranarison.github.io/mes/) in your browser. + +## Contents +- [Supported platforms](#supported-platforms) +- [Features](#features) +- [Mappers](#mappers) +- [Build](#build) +- [Resources](#resources) + +## Supported platforms + +- [x] Web +- [x] Android +- [ ] Desktop +- [ ] Embedded (ESP32) ## Features -- Almost cycle accurate emulation - Supports [iNES 1.0](https://www.nesdev.org/wiki/INES) file format - Supports basic [mappers](#mappers) - Fairly decent audio quality @@ -21,12 +34,59 @@ A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now] - [UXROM](https://nesdir.github.io/mapper2.html) (2) - [CNROM](https://nesdir.github.io/mapper2.html) (3) -## TODOs +## Build + +> [!IMPORTANT] +> The Rust [toolchain](https://rustup.rs/) is required to build the main library. + +### Web + +![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white) +![WebAssembly](https://img.shields.io/badge/WebAssembly-654FF0?logo=webassembly&logoColor=fff&style=for-the-badge) + +**Requirements**: + +- [NodeJS](https://nodejs.org/en) +- [wasmpack](https://rustwasm.github.io/wasm-pack/) + +**Scripts**: + +```bash +npm run wasm # build the WASM artifacts using wasmpack +npm run dev # run the dev server +npm run build # build the website +``` + +### Android + +![Kotlin](https://img.shields.io/badge/kotlin-%237F52FF.svg?style=for-the-badge&logo=kotlin&logoColor=white) + +**Requirements**: + +- [Android studio](https://developer.android.com/studio) +- [NDK](https://developer.android.com/ndk) +- `aarch64-linux-android` and `x86_64-linux-android` Rust targets + +**Setup**: + +Edit your global cargo config in `~/.cargo/cargo.toml` and use linkers from NDK: + +```toml +[target.aarch64-linux-android] +linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang" + +[target.x86_64-linux-android] +linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang" +``` + +**Gradle scripts**: -- [ ] Settings interface (controllers, palette, ...) -- [ ] Versions for other platforms (mobile, desktop, ...) +- `buildRustArm64`: Build the shared library for arm64 +- `buildRustx86_64`: Build the shared library for x86_64 +- `buildRs`: Runs both -## References +## Resources This project wouldn't have been possible without the help of the following ressources: diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..cc17c58 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,17 @@ +*.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 +*.so +app/release diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 0000000..3e35f38 --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +Mes \ No newline at end of file diff --git a/android/.idea/appInsightsSettings.xml b/android/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/android/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/Project.xml b/android/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/android/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca6ec97 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 0000000..7b3006b --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cde3e19 --- /dev/null +++ b/android/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,57 @@ + + + + \ No newline at end of file diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml new file mode 100644 index 0000000..6d0ee1c --- /dev/null +++ b/android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..a6655c8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,116 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn +import java.nio.file.Path + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.aboutlibraries) +} + +android { + namespace = "dev.luckasranarison.mes" + compileSdk = 34 + + defaultConfig { + applicationId = "dev.luckasranarison.mes" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + compose = true + } + + project.tasks.preBuild.dependsOn("buildRust") +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.aboutlibraries.compose.m3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +tasks.register("buildRust") { + group = "build setup" + description = "Builds the Rust shared library used with JNI" + + dependsOn("buildRustx86_64") + dependsOn("buildRustArm64") +} + +tasks.register("buildRustArm64") { + group = "build setup" + description = "Builds the Rust shared library for arm64" + + buildRustLibrary("aarch64", "arm64-v8a") +} + +tasks.register("buildRustx86_64") { + group = "build setup" + description = "Builds the Rust shared library for x86_64" + + buildRustLibrary("x86_64") +} + +fun buildRustLibrary(rustArch: String, directoryArch: String? = null) { + val scriptFile = project.buildscript.sourceFile!! + val parentPath = Path.of(scriptFile.parent!!) + val libPath = parentPath.resolve("src/main/jniLibs") + val buildPath = parentPath.resolve("../../target/$rustArch-linux-android/release/libmes_jni.so") + val archLibPath = libPath.resolve(directoryArch ?: rustArch) + + exec { + commandLine( + "cargo", + "build", + "-p=mes-jni", + "--target=$rustArch-linux-android", + "--release" + ) + commandLine("cp", buildPath, archLibPath) + } +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/android/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/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3f0564d --- /dev/null +++ b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.luckasranarison.mes + +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.example.helloworld", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4530b3 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..5908a5c6f7b13829b329cebf373ac417cf1e3a53 GIT binary patch literal 5840 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 = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + + val EnterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = animationSpec + ) + + val ExitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = animationSpec + ) + + val PopEnterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = animationSpec + ) + + val PopExitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = animationSpec + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt new file mode 100644 index 0000000..8060574 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt @@ -0,0 +1,37 @@ +package dev.luckasranarison.mes.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.byteArrayPreferencesKey +import kotlinx.coroutines.flow.map + +class SettingsRepository(private val dataStore: DataStore) { + object Keys { + val ROM_DIR = stringPreferencesKey("rom_dir") + val ENABLE_APU = booleanPreferencesKey("enable_apu") + val COLOR_PALETTE = byteArrayPreferencesKey("color_palette") + } + + suspend fun setRomDirectory(dir: String) { + dataStore.edit { pref -> pref[Keys.ROM_DIR] = dir } + } + + suspend fun toggleApuState() { + dataStore.edit { pref -> pref[Keys.ENABLE_APU] = !(pref[Keys.ENABLE_APU] ?: true) } + } + + suspend fun setColorPalette(palette: ByteArray?) { + if (palette != null) { + dataStore.edit { pref -> pref[Keys.COLOR_PALETTE] = palette } + } else { + dataStore.edit { pref -> pref.remove(Keys.COLOR_PALETTE) } + } + } + + fun getRomDirectory() = dataStore.data.map { pref -> pref[Keys.ROM_DIR] } + fun getApuState() = dataStore.data.map { pref -> pref[Keys.ENABLE_APU] } + fun getColorPalette() = dataStore.data.map { pref -> pref[Keys.COLOR_PALETTE] } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt new file mode 100644 index 0000000..7055031 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt @@ -0,0 +1,6 @@ +package dev.luckasranarison.mes.data + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore by preferencesDataStore(name = "settings") \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt new file mode 100644 index 0000000..5d23170 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt @@ -0,0 +1,43 @@ +package dev.luckasranarison.mes.data + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class RomHeader( + @SerialName("prg_rom_pages") val prgRomPages: Byte, + @SerialName("chr_rom_pages") val chrRomPages: Byte, + @SerialName("prg_ram_pages") val prgRamPages: Byte, + val mirroring: String, + val battery: Boolean, + val trainer: Boolean, + val mapper: Short +) + +data class RomFile( + val name: String, + val uri: Uri, + val size: Long, + val header: RomHeader +) { + private val attributesRegex = Regex("\\((.*?)\\)|\\[(.*?)]") + + constructor(file: DocumentFile, metadata: String) : this( + name = file.name ?: "Unknown", + uri = file.uri, + size = file.length(), + header = Json.decodeFromString(metadata) + ) + + fun getAttributes() = attributesRegex + .findAll(name) + .mapNotNull { it.groups[1]?.value } + .toList() + + fun baseName() = name + .replace(".nes", "", ignoreCase = true) + .replace(attributesRegex, "") +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt new file mode 100644 index 0000000..1575fb8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt @@ -0,0 +1,23 @@ +package dev.luckasranarison.mes.extra + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import androidx.core.content.ContextCompat.getSystemService +import dev.luckasranarison.mes.MainActivity +import dev.luckasranarison.mes.data.RomFile + +fun createShortcut(ctx: Context, rom: RomFile) { + val shortcutManager = getSystemService(ctx, ShortcutManager::class.java) + + val intent = Intent(Intent.ACTION_VIEW, rom.uri, ctx, MainActivity::class.java) + intent.putExtra("path", rom.uri.toString()) + + val shortcut = ShortcutInfo.Builder(ctx, rom.uri.toString()) + .setShortLabel(rom.baseName()) + .setIntent(intent) + .build() + + shortcutManager?.requestPinShortcut(shortcut, null) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt new file mode 100644 index 0000000..8f7b50c --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt @@ -0,0 +1,28 @@ +package dev.luckasranarison.mes.lib + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack + +fun createAudioTrack(): AudioTrack { + val sampleRate = 44100 + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_FLOAT + val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) + + return AudioTrack( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .setEncoding(audioFormat) + .build(), + minBufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt new file mode 100644 index 0000000..b3d916a --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt @@ -0,0 +1,17 @@ +package dev.luckasranarison.mes.lib + +import kotlin.experimental.and +import kotlin.experimental.inv +import kotlin.experimental.or + +class Controller(private var value: Byte = 0b0000_0000) { + fun update(button: Button, state: Boolean): Controller { + val bits = (1 shl button.ordinal).toByte() + val value = if (state) value or bits else value and bits.inv() + return Controller(value) + } + + fun state(): Byte = value +} + +enum class Button { Right, Left, Down, Up, Start, Select, B, A } \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt new file mode 100644 index 0000000..08933bd --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt @@ -0,0 +1,71 @@ +package dev.luckasranarison.mes.lib + +import android.util.Log + +typealias NesPtr = Long + +object Nes { + external fun init(): NesPtr + external fun reset(nes: NesPtr) + external fun setCartridge(nes: NesPtr, bytes: ByteArray) + external fun stepFrame(nes: NesPtr) + external fun stepVBlank(nes: NesPtr) + external fun fillAudioBuffer(nes: NesPtr, buffer: FloatArray): Int + external fun clearAudioBuffer(nes: NesPtr) + external fun fillFrameBuffer(nes: NesPtr, buffer: IntArray, palette: ByteArray?) + external fun setControllerState(nes: NesPtr, id: Long, state: Byte) + external fun free(nes: NesPtr) + external fun serializeRomHeader(rom: ByteArray): String +} + +const val AUDIO_BUFFER_SIZE = 1024 +const val SCREEN_WIDTH = 256 +const val SCREEN_HEIGHT = 240 +const val FRAME_BUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT +const val COLOR_PALETTE_SIZE = 192 +const val FRAME_DURATION = 1_000_000_000 / 60 +const val PRG_ROM_PAGE_SIZE = 16384; +const val PRG_RAM_SIZE = 8192; +const val CHR_ROM_PAGE_SIZE = 8192; +val INES_ASCII = byteArrayOf(0x4E, 0x45, 0x53, 0x1A) + +class NesObject { + private val ptr = Nes.init() + private val audioBuffer = FloatArray(AUDIO_BUFFER_SIZE) + private val frameBuffer = IntArray(FRAME_BUFFER_SIZE) + private var colorPalette: ByteArray? = null + + init { + Log.i("mes", "Emulator instance was created") + } + + fun reset() = Nes.reset(ptr) + fun setCartridge(bytes: ByteArray) = Nes.setCartridge(ptr, bytes) + fun stepFrame() = Nes.stepFrame(ptr) + fun stepVBlank() = Nes.stepVBlank(ptr) + fun clearAudioBuffer() = Nes.clearAudioBuffer(ptr) + fun setControllerState(id: Long, state: Byte) = Nes.setControllerState(ptr, id, state) + + fun updateFrameBuffer(): IntArray { + Nes.fillFrameBuffer(ptr, frameBuffer, colorPalette) + return frameBuffer + } + + fun updateAudioBuffer(): Pair { + val length = Nes.fillAudioBuffer(ptr, audioBuffer) + return Pair(audioBuffer, length) + } + + fun setColorPalette(palette: ByteArray?) { + if (palette == null || palette.size == COLOR_PALETTE_SIZE) { + colorPalette = palette + } else { + throw Exception("Invalid color palette") + } + } + + fun free() { + Nes.free(ptr) + Log.i("mes", "Emulator instance was destroyed") + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt new file mode 100644 index 0000000..6c56564 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt @@ -0,0 +1,5 @@ +package dev.luckasranarison.mes.lib + +object Rust { + external fun setPanicHook() +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt new file mode 100644 index 0000000..d803dd2 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt @@ -0,0 +1,52 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController + +@Composable +fun EmulatorBackHandler( + controller: NavHostController, + pauseEmulation: () -> Unit, + resumeEmulation: () -> Unit, + isShortcutLaunch: Boolean, +) { + val ctx = LocalContext.current as Activity + var showExitDialog by remember { mutableStateOf(false) } + + BackHandler { showExitDialog = true } + + LaunchedEffect(showExitDialog) { + if (showExitDialog) { + pauseEmulation() + } else { + resumeEmulation() + } + } + + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text(text = "Confirm to exit") }, + text = { Text(text = "Are you sure to stop the emulation?") }, + confirmButton = { + TextButton(onClick = { + when (isShortcutLaunch) { + true -> ctx.finishAffinity() + else -> controller.popBackStack() + } + }) { + Text(text = "Confirm") + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text(text = "Cancel") + } + }, + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt new file mode 100644 index 0000000..37520e3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt @@ -0,0 +1,51 @@ +package dev.luckasranarison.mes.ui.emulator + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.lib.createAudioTrack +import dev.luckasranarison.mes.ui.gamepad.GamePadLayout +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun Emulator(viewModel: EmulatorViewModel, controller: NavHostController) { + val ctx = LocalContext.current + val emulatorView = remember { EmulatorView(ctx) } + val audioTrack = remember { createAudioTrack() } + val isRunning by viewModel.isRunning + val isShortcutLaunch by viewModel.isShortcutLaunch + + DisposableEffect(Unit) { + viewModel.startEmulation() + audioTrack.play() + + onDispose { + audioTrack.stop() + audioTrack.release() + } + } + + LaunchedEffect(isRunning) { + viewModel.runMainLoop(emulatorView, audioTrack) + } + + EmulatorBackHandler( + controller = controller, + pauseEmulation = viewModel::pauseEmulation, + resumeEmulation = viewModel::startEmulation, + isShortcutLaunch = isShortcutLaunch, + ) + + FullScreenLandscapeBox { + AndroidView( + factory = { emulatorView }, + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + ) + GamePadLayout(viewModel = viewModel) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt new file mode 100644 index 0000000..e6aece3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt @@ -0,0 +1,39 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import androidx.core.graphics.scale +import dev.luckasranarison.mes.lib.SCREEN_HEIGHT +import dev.luckasranarison.mes.lib.SCREEN_WIDTH + +class EmulatorView(context: Context) : View(context) { + private val screen: Bitmap = + Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888) + + init { + val pixels = Array(SCREEN_WIDTH * SCREEN_HEIGHT) { 0xFFFFFFFF.toInt() } + screen.setPixels(pixels.toIntArray(), 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val viewWidth = width.toFloat() + val viewHeight = height.toFloat() + + val aspectRatio = SCREEN_HEIGHT.toFloat() / SCREEN_WIDTH.toFloat() + val scaledWidth = viewHeight / aspectRatio + val scaledBitmap = screen.scale(scaledWidth.toInt(), viewHeight.toInt(), false) + val left = (viewWidth - scaledWidth) / 2 + + canvas.drawBitmap(scaledBitmap, left, 0f, Paint()) + } + + fun updateScreenData(buffer: IntArray) { + screen.setPixels(buffer, 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + invalidate() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt new file mode 100644 index 0000000..a0898c6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt @@ -0,0 +1,45 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.app.Activity +import android.content.pm.ActivityInfo +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +fun FullScreenLandscapeBox(content: @Composable (BoxScope.() -> Unit)) { + val view = LocalView.current + val ctx = LocalContext.current as Activity + + DisposableEffect(Unit) { + val insetsController = WindowCompat.getInsetsController(ctx.window, view) + val systemBars = WindowInsetsCompat.Type.systemBars() + + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + insetsController.hide(systemBars) + ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + + onDispose { + insetsController.show(systemBars) + ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { content() } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt new file mode 100644 index 0000000..b7abac2 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt @@ -0,0 +1,40 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun ActionButton(text: String, onPress: (Boolean) -> Unit) { + BaseButton( + modifier = Modifier + .clip(CircleShape) + .size(48.dp), + onPress = { state -> onPress(state) }, + ) { + Text( + text = text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } +} + +@Composable +fun ActionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + ActionButton(text = "B") { state -> onPress(Button.B, state) } + ActionButton(text = "A") { state -> onPress(Button.A, state) } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt new file mode 100644 index 0000000..f397cea --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt @@ -0,0 +1,46 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput + +@Composable +fun BaseButton( + modifier: Modifier, + onPress: (Boolean) -> Unit, + content: @Composable (BoxScope.() -> Unit) = {} +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background( + Color.Gray.copy( + alpha = if (isPressed) 0.5f else 0.8f + ) + ) + .pointerInput(Unit) { + detectTapGestures(onPress = { + try { + onPress(true) + isPressed = true + awaitRelease() + } finally { + onPress(false) + isPressed = false + } + }) + } + ) { content() } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt new file mode 100644 index 0000000..747c1ca --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt @@ -0,0 +1,79 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun DirectionButton( + modifier: Modifier, + icon: ImageVector, + desc: String, + onPress: (Boolean) -> Unit +) { + BaseButton( + modifier = modifier.size(52.dp), + onPress = { state -> onPress(state) }, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + modifier = Modifier.graphicsLayer(scaleX = 1.2f, scaleY = 1.2f) + ) + } +} + +@Composable +fun DirectionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Box(modifier = modifier.size((52 * 3).dp)) { + DirectionButton( + icon = Icons.Default.KeyboardArrowUp, + desc = "Up", + modifier = Modifier + .align(Alignment.TopCenter) + .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)), + onPress = { state -> onPress(Button.Up, state) }, + ) + DirectionButton( + icon = Icons.Default.KeyboardArrowDown, + desc = "Down", + modifier = Modifier + .align(Alignment.BottomCenter) + .clip(RoundedCornerShape(bottomStart = 5.dp, bottomEnd = 5.dp)), + onPress = { state -> onPress(Button.Down, state) }, + ) + DirectionButton( + icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + desc = "Left", + modifier = Modifier + .align(Alignment.CenterStart) + .clip(RoundedCornerShape(topStart = 5.dp, bottomStart = 5.dp)), + onPress = { state -> onPress(Button.Left, state) }, + ) + DirectionButton( + icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + desc = "Right", + modifier = Modifier + .align(Alignment.CenterEnd) + .clip(RoundedCornerShape(topEnd = 5.dp, bottomEnd = 5.dp)), + onPress = { state -> onPress(Button.Right, state) }, + ) + } +} diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt new file mode 100644 index 0000000..8c0ceea --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt @@ -0,0 +1,69 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.settings.FloatingSettings +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun GamePadLayout(viewModel: EmulatorViewModel) { + var showSettings by remember { mutableStateOf(false) } + + if (showSettings) { + FloatingSettings( + viewModel = viewModel, + onExit = { showSettings = false } + ) + } + + LaunchedEffect(showSettings) { + if (showSettings) { + viewModel.pauseEmulation() + } else { + viewModel.startEmulation() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + DirectionPad( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 72.dp, top = 72.dp), + onPress = viewModel::updateController + ) + MenuPad( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + onPress = viewModel::updateController + ) + ActionPad( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 48.dp, top = 72.dp), + onPress = viewModel::updateController + ) + IconButton( + onClick = { showSettings = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(24.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = Color.Gray, + modifier = Modifier.size(32.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt new file mode 100644 index 0000000..80952c3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt @@ -0,0 +1,37 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun MenuButton(text: String, onPress: (Boolean) -> Unit) { + BaseButton( + modifier = Modifier.clip(RoundedCornerShape(5.dp)), + onPress = { state -> onPress(state) }, + ) { + Text( + text = text, + fontSize = 10.sp, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 10.dp), + fontWeight = FontWeight.Bold, + color = Color.White + ) + } +} + +@Composable +fun MenuPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + MenuButton(text = "SELECT") { state -> onPress(Button.Select, state) } + MenuButton(text = "START") { state -> onPress(Button.Start, state) } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt new file mode 100644 index 0000000..48faedb --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt @@ -0,0 +1,28 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DirectoryChooser(modifier: Modifier, onChoose: () -> Unit) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "ROM directory is not set. Please choose one") + Button(onClick = onChoose) { + Text(text = "Add directory") + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt new file mode 100644 index 0000000..77916aa --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt @@ -0,0 +1,38 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R + +@Composable +fun FloatingButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.BottomEnd + ) { + FloatingActionButton ( + onClick = onClick, + containerColor = MaterialTheme.colorScheme.primary, + elevation = FloatingActionButtonDefaults.elevation(2.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.nes_icon), + contentDescription = "Upload", + modifier = Modifier.size(24.dp), + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt new file mode 100644 index 0000000..28e15e6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt @@ -0,0 +1,93 @@ +package dev.luckasranarison.mes.ui.home + +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Activities +import dev.luckasranarison.mes.Routes +import dev.luckasranarison.mes.vm.EmulatorViewModel +import dev.luckasranarison.mes.vm.RomLoadingState +import dev.luckasranarison.mes.ui.rom.RomList + +@Composable +fun Home(viewModel: EmulatorViewModel, controller: NavHostController) { + val ctx = LocalContext.current + val romFiles by viewModel.romFiles + val romLoadingState by viewModel.romLoadingState + val romDirectory by viewModel.romDirectory.observeAsState() + var showInfoDialog by remember { mutableStateOf(false) } + + val loadRomFromFile = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri -> + if (uri != null) viewModel.loadRomFromFile(ctx, uri) + } + + val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri -> + if (uri != null) viewModel.setRomDirectory(ctx, uri) + } + + LaunchedEffect(romDirectory) { + if (romDirectory != null) { + viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory)) + } + } + + LaunchedEffect(romLoadingState) { + if (romLoadingState is RomLoadingState.Success) { + Log.i("mes", "launching emulator...") + controller.navigate(Routes.EMULATOR) + viewModel.setLoadStatus(RomLoadingState.None) + Log.i("mes", "loading state: $romLoadingState") + } + + if (romLoadingState is RomLoadingState.Error) { + val errorMessage = (romLoadingState as RomLoadingState.Error).message + Toast.makeText(ctx, errorMessage, Toast.LENGTH_SHORT).show() + viewModel.setLoadStatus(RomLoadingState.None) + } + } + + Scaffold( + topBar = { + HomeTopAppBar(controller = controller) + }, + floatingActionButton = { + FloatingButton(onClick = { loadRomFromFile.launch("application/octet-stream") }) + } + ) { innerPadding -> + when { + romDirectory != null && romFiles == null -> { // Loading + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.primary + ) + } + } + + romFiles == null -> DirectoryChooser( + modifier = Modifier.padding(innerPadding), + onChoose = { chooseRomDirectory.launch(null) } + ) + + else -> RomList( + modifier = Modifier.padding(innerPadding), + onRefresh = { viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory)) }, + onSelect = { uri -> viewModel.loadRomFromFile(ctx, uri) }, + romFiles = romFiles!! + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt new file mode 100644 index 0000000..865db02 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt @@ -0,0 +1,38 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Routes + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun HomeTopAppBar(controller: NavHostController) { + TopAppBar( + title = { Text("Mes Emulator") }, + actions = { + Row { + IconButton(onClick = { controller.navigate(Routes.INFO) }) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info" + ) + } + IconButton(onClick = { controller.navigate(Routes.SETTINGS) }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings" + ) + } + } + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt new file mode 100644 index 0000000..c488359 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt @@ -0,0 +1,27 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R + +@Composable +fun AppIcon() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.nes_icon), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .padding(48.dp) + .size(82.dp), + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt new file mode 100644 index 0000000..c43a8a6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt @@ -0,0 +1,64 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Routes +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar + +const val AUTHOR_EMAIL = "luckasranarison@gmail.com" +const val REPOSITORY_URL = "https://github.com/luckasranarison/mes" + +@Composable +fun Info(controller: NavHostController) { + val ctx = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val uriHandler = LocalUriHandler.current + val version = remember { getAppVersion(ctx) } + + Scaffold( + topBar = { + GenericTopAppBar( + title = "About", + onExit = { controller.popBackStack() } + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + AppIcon() + + HorizontalDivider(thickness = 1.dp) + + Section( + title = "Version", + description = version, + onClick = { clipboardManager.setText(AnnotatedString("Mes v${version}")) } + ) + Section( + title = "Author", + description = AUTHOR_EMAIL, + onClick = { uriHandler.openUri(makeMailMessage(AUTHOR_EMAIL)) } + ) + Section( + title = "Source", + description = REPOSITORY_URL, + onClick = { uriHandler.openUri(REPOSITORY_URL) } + ) + Section( + title = "Open source license", + onClick = { controller.navigate(Routes.LICENSE) } + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt new file mode 100644 index 0000000..2bf787f --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt @@ -0,0 +1,30 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun Section(title: String, description: String? = null, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Text(text = title) + + if (description != null) { + Text( + text = description, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt new file mode 100644 index 0000000..d78eb8f --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt @@ -0,0 +1,10 @@ +package dev.luckasranarison.mes.ui.info + +import android.content.Context + +fun getAppVersion(ctx: Context) = + ctx.packageManager?.getPackageInfo(ctx.packageName, 0)?.versionName + ?: throw Exception("Failed to get package info") + +fun makeMailMessage(address: String) = + "https://mail.google.com/mail/?view=cm&fs=1&to=$address&su=Subject&body=Message" \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt new file mode 100644 index 0000000..84a9545 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt @@ -0,0 +1,76 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.entity.Library +import dev.luckasranarison.mes.R +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BottomSheet(library: Library, onClose: () -> Unit) { + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxHeight(0.6f) + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = library.name, fontWeight = FontWeight.SemiBold) + Text( + text = "v${library.artifactVersion}", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + + if (library.website != null) { + IconButton(onClick = { uriHandler.openUri(library.website!!) }) { + Icon( + painter = painterResource(id = R.drawable.web_search), + contentDescription = "Website" + ) + } + } + } + + Text( + text = library.description ?: "No description", + modifier = Modifier.padding(16.dp) + ) + + HorizontalDivider(thickness = 1.dp) + + Text( + text = library.licenses + .mapNotNull { it.licenseContent } + .joinToString("\n"), + modifier = Modifier.padding(16.dp), + style = Typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt new file mode 100644 index 0000000..7b68c4a --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt @@ -0,0 +1,50 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.ui.compose.m3.util.author +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun LibraryContainer(lib: Library) { + var showSheet by remember { mutableStateOf(false) } + + if (showSheet) { + BottomSheet(library = lib, onClose = { showSheet = false }) + } + + Box(modifier = Modifier + .fillMaxWidth() + .clickable { showSheet = true } + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = lib.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = lib.author, + style = Typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + Text(text = lib.artifactVersion ?: "Unknown") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt new file mode 100644 index 0000000..32bb9b1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt @@ -0,0 +1,40 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import com.mikepenz.aboutlibraries.ui.compose.m3.rememberLibraries +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar +import dev.luckasranarison.mes.R + +@Composable +fun License(controller: NavHostController) { + val ctx = LocalContext.current + + val libs by rememberLibraries { + ctx.resources + .openRawResource(R.raw.aboutlibraries) + .readBytes() + .decodeToString() + } + + Scaffold( + topBar = { + GenericTopAppBar( + title = "Open source license", + onExit = { controller.popBackStack() } + ) + } + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(libs?.libraries?.size ?: 0) { index -> + LibraryContainer(libs!!.libraries[index]) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt new file mode 100644 index 0000000..9ef48d1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt @@ -0,0 +1,41 @@ +package dev.luckasranarison.mes.ui.rom + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun InitialBox( + name: String, + modifier: Modifier, + foreground: Color, + background: Color +) { + Box( + modifier = modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(background), + contentAlignment = Alignment.Center + ) { + Text( + text = name + .split(" ") + .take(3) + .mapNotNull { it.firstOrNull() } + .joinToString("") + .ifEmpty { "NES" }, + style = Typography.titleSmall, + color = foreground, + ) + } +} diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt new file mode 100644 index 0000000..0d4b2d1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt @@ -0,0 +1,72 @@ +package dev.luckasranarison.mes.ui.rom + +import android.net.Uri +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +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 dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.ui.rom.sheet.BottomSheet +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun RomContainer(rom: RomFile, onSelect: (Uri) -> Unit) { + var isSheetVisible by remember { mutableStateOf(false) } + + if (isSheetVisible) { + BottomSheet( + rom = rom, + onClose = { isSheetVisible = false }, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable { onSelect(rom.uri) } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + InitialBox( + name = rom.baseName(), + modifier = Modifier.padding(start = 8.dp), + foreground = MaterialTheme.colorScheme.onSecondary, + background = MaterialTheme.colorScheme.secondary + ) + + Text( + text = rom.baseName(), + style = Typography.titleMedium, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + IconButton(onClick = { isSheetVisible = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Details", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt new file mode 100644 index 0000000..769ddd8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt @@ -0,0 +1,60 @@ +package dev.luckasranarison.mes.ui.rom + +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.* +import androidx.compose.runtime.* +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.luckasranarison.mes.data.RomFile +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RomList( + modifier: Modifier, + romFiles: List, + onSelect: (Uri) -> Unit, + onRefresh: suspend () -> Unit +) { + val pullToRefreshState = rememberPullToRefreshState() + val coroutineScope = rememberCoroutineScope() + var isRefreshing by remember { mutableStateOf(false) } + + PullToRefreshBox( + modifier = modifier.fillMaxSize(), + state = pullToRefreshState, + isRefreshing = isRefreshing, + onRefresh = { + coroutineScope.launch { + isRefreshing = true + onRefresh() + isRefreshing = false + } + }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = Color.White, + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(romFiles.size) { index -> + val rom = romFiles[index] + RomContainer( + rom = rom, + onSelect = { onSelect(rom.uri) }, + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt new file mode 100644 index 0000000..7143fd1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt @@ -0,0 +1,55 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.ui.theme.Typography +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BottomSheet( + rom: RomFile, + onClose: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = { onClose() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + TopRow(rom = rom) + + Spacer(modifier = Modifier.height(16.dp)) + + MetadataList(rom = rom) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + scope.launch { sheetState.hide(); onClose() } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.primary) + ) { + Text( + "Close", + style = Typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt new file mode 100644 index 0000000..78a79a0 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt @@ -0,0 +1,55 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.lib.CHR_ROM_PAGE_SIZE +import dev.luckasranarison.mes.lib.PRG_RAM_SIZE +import dev.luckasranarison.mes.lib.PRG_ROM_PAGE_SIZE +import dev.luckasranarison.mes.ui.theme.Typography + +fun formatPage(count: Byte, size: Int) = + if (count > 0) "$count (${count * size / 1024} KB)" else "None" + +@Composable +fun Metadata(key: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = key, + style = Typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = Typography.bodyMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun MetadataList(rom: RomFile) { + val attributes = rom.getAttributes() + + Metadata("Attributes", if (attributes.isEmpty()) "None" else attributes.joinToString()) + Metadata("Size", "${rom.size / 1024} KB") + Metadata("Mapper", rom.header.mapper.toString()) + Metadata("Mirroring", rom.header.mirroring) + Metadata("Battery", if (rom.header.battery) "Yes" else "No") + Metadata("PRG ROM", formatPage(rom.header.prgRomPages, PRG_ROM_PAGE_SIZE)) + Metadata("PRG RAM", formatPage(rom.header.prgRamPages, PRG_RAM_SIZE)) + Metadata("CHR ROM", formatPage(rom.header.chrRomPages, CHR_ROM_PAGE_SIZE)) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt new file mode 100644 index 0000000..1014fa8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt @@ -0,0 +1,52 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.extra.createShortcut +import dev.luckasranarison.mes.ui.rom.InitialBox +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun TopRow(rom: RomFile) { + val ctx = LocalContext.current + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + InitialBox( + name = rom.baseName(), + modifier = Modifier, + foreground = MaterialTheme.colorScheme.onPrimary, + background = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = rom.baseName(), + style = Typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + IconButton(onClick = { createShortcut(ctx, rom) }) { + Icon( + painter = painterResource(id = R.drawable.app_shortcut), + contentDescription = "Shortcut", + modifier = Modifier.size(20.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt new file mode 100644 index 0000000..9d7c1a5 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt @@ -0,0 +1,39 @@ +package dev.luckasranarison.mes.ui.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +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.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun FloatingSettings(viewModel: EmulatorViewModel, onExit: () -> Unit) { + Dialog( + onDismissRequest = onExit, + properties = DialogProperties( + usePlatformDefaultWidth = false, + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .fillMaxHeight() + ) { + Card( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .align(Alignment.Center) + ) { + Settings(viewModel = viewModel, onExit = onExit) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..7f9cf14 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt @@ -0,0 +1,96 @@ +package dev.luckasranarison.mes.ui.settings + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.Activities +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun Settings(viewModel: EmulatorViewModel, onExit: () -> Unit) { + val ctx = LocalContext.current + val romDirectory by viewModel.romDirectory.observeAsState() + val enableApu by viewModel.enableApu.observeAsState() + val colorPalette by viewModel.colorPalette.observeAsState() + var showPaletteOptions by remember { mutableStateOf(false) } + + val wrapBlock: (() -> Unit) -> Unit = { block -> + try { + block() + } catch (err: Exception) { + val message = err.message ?: "An unknown error occurred" + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri -> + if (uri != null) wrapBlock { viewModel.setRomDirectory(ctx, uri) } + } + + val chooseColorPalette = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri -> + if (uri != null) wrapBlock { viewModel.setColorPalette(ctx, uri) } + + } + + LaunchedEffect(colorPalette) { + showPaletteOptions = colorPalette != null + } + + Scaffold( + topBar = { + GenericTopAppBar(title = "Settings", onExit = onExit) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Section(header = "ROMs") { + TextValue( + label = "Directory", + value = romDirectory?.toPathName(ctx) ?: "Unset", + onChange = { chooseRomDirectory.launch(null) } + ) + } + Section(header = "Emulator") { + BooleanValue( + label = "Custom palette", + description = "Use custom .pal palette", + value = showPaletteOptions, + onToggle = { value -> + showPaletteOptions = value + if (!value) viewModel.unsetColorPalette() + } + ) + + if (showPaletteOptions) { + TextValue( + label = "Palette", + value = "Custom palette file", + onChange = { chooseColorPalette.launch("*/*") } + ) + } + + BooleanValue( + label = "Sound", + description = "Enable APU emulation", + value = enableApu ?: true, + onToggle = { viewModel.toggleApuState() } + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt new file mode 100644 index 0000000..3e914ca --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt @@ -0,0 +1,81 @@ +package dev.luckasranarison.mes.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun TextValue(label: String, value: String, onChange: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onChange() } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = label, + modifier = Modifier, + ) + Text( + text = value, + modifier = Modifier, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } +} + +@Composable +fun BooleanValue(label: String, description: String, value: Boolean, onToggle: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(value) } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + modifier = Modifier, + ) + Text( + text = description, + modifier = Modifier, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + Switch( + checked = value, + onCheckedChange = onToggle, + colors = SwitchDefaults.colors( + uncheckedBorderColor = MaterialTheme.colorScheme.onBackground, + uncheckedTrackColor = MaterialTheme.colorScheme.onBackground, + uncheckedThumbColor = MaterialTheme.colorScheme.background, + ) + ) + } +} + +@Composable +fun Section(header: String, options: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = header, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.primary, + style = Typography.titleMedium + ) + Column { options() } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt new file mode 100644 index 0000000..51ef155 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt @@ -0,0 +1,11 @@ +package dev.luckasranarison.mes.ui.settings + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile + +fun String.toPathName(context: Context): String { + val uri = this.toUri() + val documentFile = DocumentFile.fromTreeUri(context, uri) + return documentFile?.name ?: "Unknown Directory" +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt new file mode 100644 index 0000000..92b3970 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt @@ -0,0 +1,29 @@ +package dev.luckasranarison.mes.ui.shared + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun GenericTopAppBar( + title: String, + onExit: () -> Unit, +) { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt new file mode 100644 index 0000000..2851610 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package dev.luckasranarison.mes.ui.theme + +import androidx.compose.ui.graphics.Color + +data object ColorScheme { + val Primary = Color(0xFFE60012) + val Secondary = Color(0xFF484848) + val Light = Color(0xFFFFFFFF) + val Dark = Color(0xFF1E1E1E) + val Smoke = Color(0x00000020) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt new file mode 100644 index 0000000..99af6b5 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt @@ -0,0 +1,48 @@ +package dev.luckasranarison.mes.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = ColorScheme.Primary, + secondary = ColorScheme.Secondary, + tertiary = ColorScheme.Smoke, + background = ColorScheme.Dark, + onBackground = ColorScheme.Light, + onPrimary = ColorScheme.Light, + onSecondary = ColorScheme.Light, + surface = ColorScheme.Dark, + surfaceTint = ColorScheme.Dark, +) + +private val LightColorScheme = lightColorScheme( + primary = ColorScheme.Primary, + secondary = ColorScheme.Secondary, + tertiary = ColorScheme.Smoke, + background = ColorScheme.Light, + onBackground = ColorScheme.Dark, + onPrimary = ColorScheme.Light, + onSecondary = ColorScheme.Light, + surface = ColorScheme.Light, + surfaceTint = ColorScheme.Light, +) + +@Composable +fun MesTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt new file mode 100644 index 0000000..dc8a7fa --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt @@ -0,0 +1,17 @@ +package dev.luckasranarison.mes.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( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt new file mode 100644 index 0000000..9f5a8ab --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt @@ -0,0 +1,202 @@ +package dev.luckasranarison.mes.vm + +import android.content.Context +import android.content.Intent +import android.media.AudioTrack +import android.net.Uri +import android.provider.DocumentsContract +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.* +import androidx.lifecycle.viewmodel.CreationExtras +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.data.SettingsRepository +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import dev.luckasranarison.mes.data.dataStore +import dev.luckasranarison.mes.lib.* +import dev.luckasranarison.mes.ui.emulator.EmulatorView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.mapNotNull +import java.io.IOException + +class EmulatorViewModel(private val settings: SettingsRepository) : ViewModel() { + private val _romLoadingState = mutableStateOf(RomLoadingState.None) + private val _isRunning = mutableStateOf(false) + private val _romFiles = mutableStateOf?>(null) + private val _isShortcutLaunch = mutableStateOf(false) + private val nes: NesObject = NesObject() + private val controller = mutableStateOf(Controller()) + + val romDirectory = settings.getRomDirectory().asLiveData() + val enableApu = settings.getApuState().asLiveData() + val colorPalette = settings.getColorPalette().asLiveData() + val romLoadingState: State = _romLoadingState + val romFiles: State?> = _romFiles + val isRunning: State = _isRunning + val isShortcutLaunch: State = _isShortcutLaunch + + init { + viewModelScope.launch { + settings.getColorPalette() + .mapNotNull { it } + .collect{ nes.setColorPalette(it) } + } + } + + override fun onCleared() { + super.onCleared() + nes.free() + } + + fun loadRomFromFile(ctx: Context, uri: Uri) { + try { + val stream = ctx.contentResolver.openInputStream(uri) + + stream.use { handle -> + val rom = handle?.readBytes() ?: throw IOException("Failed to read ROM") + nes.setCartridge(rom) + nes.reset() + _romLoadingState.value = RomLoadingState.Success + } + } catch (err: Exception) { + val message = err.message ?: "An unknown error occurred" + _romLoadingState.value = RomLoadingState.Error(message) + } + } + + suspend fun loadRomFromDirectory(ctx: Context, uri: Uri) { + withContext(Dispatchers.IO) { + val parentId = DocumentsContract.getTreeDocumentId(uri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, parentId) + val tree = DocumentFile.fromTreeUri(ctx, childrenUri) + val files = tree?.listFiles() + ?.mapNotNull { file -> runCatching { readRomMetadata(ctx, file) }.getOrNull() } + + _romFiles.value = files + } + } + + private fun readRomMetadata(ctx: Context, file: DocumentFile): RomFile { + val stream = ctx.contentResolver.openInputStream(file.uri) + + stream?.use { handle -> + val headerBuffer = ByteArray(4) + val bytesRead = handle.read(headerBuffer, 0, 4) + + if (bytesRead == 4 && headerBuffer contentEquals INES_ASCII) { + val remaining = handle.readBytes() + val stringMetaData = Nes.serializeRomHeader(headerBuffer + remaining) + return RomFile(file, stringMetaData) + } + } + + throw Exception("Not a valid iNES file") + } + + fun setShortcutLaunch() { + _isShortcutLaunch.value = true + } + + fun setRomDirectory(ctx: Context, uri: Uri) { + ctx.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + viewModelScope.launch { settings.setRomDirectory(uri.toString()) } + } + + fun setLoadStatus(state: RomLoadingState) { + _romLoadingState.value = state + } + + fun pauseEmulation() { + _isRunning.value = false + } + + fun startEmulation() { + _isRunning.value = true + } + + fun updateController(button: Button, state: Boolean) { + controller.value = controller.value.update(button, state) + } + + fun toggleApuState() { + viewModelScope.launch { + settings.toggleApuState() + } + } + + fun unsetColorPalette() { + viewModelScope.launch { + nes.setColorPalette(null) + settings.setColorPalette(null) + } + } + + fun setColorPalette(ctx: Context, uri: Uri) { + val stream = ctx.contentResolver.openInputStream(uri) + + stream?.use { handle -> + val palette = handle.readBytes() + nes.setColorPalette(palette) + viewModelScope.launch { settings.setColorPalette(palette) } + } + } + + suspend fun runMainLoop(view: EmulatorView, audio: AudioTrack) { + var lastTimestamp = System.nanoTime() + + while (isRunning.value) { + val timestamp = System.nanoTime() + val delta = timestamp - lastTimestamp + + if (delta >= FRAME_DURATION) { + lastTimestamp += FRAME_DURATION + stepFrame(view, audio) + } else { + delay((FRAME_DURATION - delta) / 1_000_000) + } + } + } + + private fun stepFrame(view: EmulatorView, audio: AudioTrack) { + nes.stepFrame() + + val frameBuffer = nes.updateFrameBuffer() + view.updateScreenData(frameBuffer) + + val (audioBuffer, length) = nes.updateAudioBuffer() + + if (enableApu.value != false) { + audio.write(audioBuffer, 0, length, AudioTrack.WRITE_NON_BLOCKING) + nes.clearAudioBuffer() + } + + nes.setControllerState(0, controller.value.state()) + nes.stepVBlank() + } + + companion object { + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + val application = extras[APPLICATION_KEY]!! + val store = application.dataStore + val repository = SettingsRepository(store) + return EmulatorViewModel(repository) as T + } + } + } +} + +sealed class RomLoadingState { + data object None : RomLoadingState() + data object Success : RomLoadingState() + data class Error(val message: String) : RomLoadingState() +} \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/.keep b/android/app/src/main/jniLibs/arm64-v8a/.keep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/jniLibs/x86_64/.keep b/android/app/src/main/jniLibs/x86_64/.keep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/res/drawable/app_shortcut.xml b/android/app/src/main/res/drawable/app_shortcut.xml new file mode 100644 index 0000000..fac9a3a --- /dev/null +++ b/android/app/src/main/res/drawable/app_shortcut.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c4e85a3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/nes_icon.xml b/android/app/src/main/res/drawable/nes_icon.xml new file mode 100644 index 0000000..946e1b4 --- /dev/null +++ b/android/app/src/main/res/drawable/nes_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/web_search.xml b/android/app/src/main/res/drawable/web_search.xml new file mode 100644 index 0000000..0e7c701 --- /dev/null +++ b/android/app/src/main/res/drawable/web_search.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..220b80a53471f9835c30c614eefa6c816292ac22 GIT binary patch literal 738 zcmV<80v-KQNk&H60ssJ4MM6+kP&iD^0ssInN5Byf-{hc;BuBE2Kg|R6!MMRc&iU1hvJ_zp80`{Fugy zs;X8MW34rfv8p2ejjBhVs{Z}E_3kn+=RExz-+n#&5>XZLZ%{=A74_@SSX9)bPt*GL zYpjTAM2tm6QN^~Zs;cm70h$IOe9H%e#jLolI$nJgME)g+`tEdmIUWrJ!5cjo5X(7R z+miOAqpG3AK*`{sTwr$(CZQHibxwehZy>S=j*q2WWbFA$>Bl^$Ak)$}) zL`2}*U$Fc8&ea(tvj8p>D1(r7oF44&?h(zv6qSD%=*i&>nHIG6OT1GSA4eM-2B%vJ~AvS zY9pqtthh*2$l3^L3JVZjL?aO+*%Env@!^8^UU^;&&pey|W*C zIvNwfh1mOUY-mtaRX3Fns2I^6%-HJ@#`|HlV>9-?Iyf?`>GA;00`>gf4avw)=C^R}5OSFhc^-(8y5 zh-Xyqe`BxgxclPe|ML0cr;iZv8KR;fDjW+Cx)|k&L9_aMeWpC@KYIB1`Vmi4-QU?$ U-adW(@Sb=S&31y)14RF#qS(f3Q2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..a40c50f5ae8e213e2195a5d5b9c544bc20bdd732 GIT binary patch literal 2120 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 E07@{~6#xJL literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..767afdcd365954fb3e49afca4c70080d11d39f07 GIT binary patch literal 960 zcmV;x13&yyNk&Gv0{{S5MM6+kP&iDi0{{RoU%(d-XXYT1Bt^;x{{r6xC7G6EeUuT2 zbdI2HWeAZ-B+`z6L@LX&O^fjI>sTMl_R622vrStZw|sl%M`aw^mzjl>5qaiMq;rs9 z7$YNyw4)u7l`RsGfDFsZ){aOE5z^5)+7XE$u}31Y!b$`NC=mie5ab+d*QxR#2p(nv zlGYwVt_I=J7O68f8adQxdhTGU>bRdPr-JL>y%QxJuZpVsEXodJlDOOz)ZBYiY2@H9{w8rW82WU4cNQ17jrDT6JM? zpxX9|8H7V2l!xG1Yu2 z^|^KT4(3c~l=`aVM_Vskxq1BtU`;{iqE%w5_|n^#t(Gzp@{4WMR|P-%;1a)9SwTRd zS?+I@m>TDey7Dq2vZ|JID)`gWS1lD~gygXHIQ3V;=$RU*CxvERwv+lPk~deAq-&R~ z%w`KwdUkVVw%du)z1Q|nSn~?dP4&cAA#JwWYjVi zqc$}bwfGsN)A_GEF{6! z@lr);Gx)e*Axb$nHJX@s&Ug$o1cnW+5*&I7;3`-x$$vm zB~C;Siu;A46FzV(U;uLp@{lc@3s_+MC$t__wh^U!j~Y8_NwR#OGJoUwJxc{CxMkf7 zF~irm~-v3xBE+-sX=WV-+c7&^unIO i$2Z)Dsw>+&eR%usJ?4gBPE?juXSR0s*lPxpek}#lrPPW5 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..6070d4a884db207f909b21c7794c16861384f3fe GIT binary patch literal 2972 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 E2Ao{3&;S4c literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..6af4f3eb3d8015831e87ced80e088a63d5ae5917 GIT binary patch literal 4748 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/android/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/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..a86a993 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E60012 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b643fb9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Mes + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ae8e7bd --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +