diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 657b410505..1e94392134 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -patreon: pojavlauncher +patreon: angelauramc diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4adbc82df9..76aa875586 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -17,7 +17,7 @@ body: attributes: label: The log file and images/videos description: | - The log file called `latestlog.txt` is located under `/storage/emulated/0/Android/data/net.kdt.pojavlaunch/files` (on Android 10 and higher) or `/storage/emulated/0/games/PojavLauncher/` (on Android 9 and lower). You may have to activate an option in your file explorer to see hidden files and folders. Recommended file managers are ZArchiver and MiXplorer. | + The log file called `latestlog.txt` is located under `/storage/emulated/0/Android/data/org.angelauramc.amethyst/files` (on Android 10 and higher) or `/storage/emulated/0/games/Copper/` (on Android 9 and lower). You may have to activate an option in your file explorer to see hidden files and folders. Recommended file managers are ZArchiver and MiXplorer. | You can also upload here screenshots or videos of the buggy behavior, if possible. Tip: You can attach files by clicking this area to highlight it and then dragging files in or select them on 🖼 option at the toolbar. Do not click anything before the file successfully uploads, otherwise the upload is going to stop and the file won't attach. @@ -29,7 +29,7 @@ body: label: Steps To Reproduce description: Steps to reproduce the issue. placeholder: | - 1. Start PojavLauncher + 1. Start Copper ... *(your set of actions to reproduce the bug)* render: markdown validations: @@ -51,12 +51,12 @@ body: - **Device model**: Huawei P30 Pro 8/512G - **CPU architecture**: arm64 (can be arm64, arm, x86_64 or x86 depending on the device) - **Android version**: 10 - - **PojavLauncher version**: (can be found in Android Apps settings, for example `dahlia-20230813-5e8842b-v3_openjdk`) + - **Copper version**: (can be found in Android Apps settings, for example `dahlia-20230813-5e8842b-v3_openjdk`) value: | - Device model: - CPU architecture: - Android version: - - PojavLauncher version: + - Copper version: render: markdown validations: required: true diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ef553a95f6..912bfba7b5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,11 +3,11 @@ name: Android CI on: pull_request: branches-ignore: - - 'l10n_v3_openjdk2' + - 'l10n_v3_openjdk' types: [ opened, reopened ] push: branches-ignore: - - 'l10n_v3_openjdk2' + - 'l10n_v3_openjdk' workflow_dispatch: jobs: @@ -18,79 +18,58 @@ jobs: CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + submodules: "recursive" - - name: Set up JDK 8 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '8' + java-version: '21' - name: Get LTW - uses: dawidd6/action-download-artifact@v2 - continue-on-error: true - with: - github_token: ${{secrets.LTW_CLONER_SECRET}} - repo: PojavLauncherTeam/BigTinyWrapper - workflow: android.yml - workflow_conclusion: success - name: output-aar - path: app_pojavlauncher/libs - allow_forks: false - - - name: Get JRE 8 - uses: dawidd6/action-download-artifact@v2 + run: | + sudo apt update && sudo apt install -y wget + mkdir -p app_pojavlauncher/libs + cd app_pojavlauncher/libs + wget https://github.com/CopperLauncher/LTW/releases/latest/download/ltw-release.aar + + - name: Get JRE8 + uses: dawidd6/action-download-artifact@v6 with: workflow: build.yml path: app_pojavlauncher/src/main/assets/components/jre workflow_conclusion: success - repo: PojavLauncherTeam/android-openjdk-build-multiarch + repo: AngelAuraMC/angelauramc-openjdk-build branch: buildjre8 name: jre8-pojav - - name: Get JRE17 - uses: dawidd6/action-download-artifact@v2 - with: - workflow: build.yml - path: app_pojavlauncher/src/main/assets/components/jre-new - workflow_conclusion: success - repo: PojavLauncherTeam/android-openjdk-build-multiarch - branch: buildjre17-21 - name: jre17-pojav - - - name: Get JRE21 - uses: dawidd6/action-download-artifact@v2 - with: - workflow: build.yml - path: app_pojavlauncher/src/main/assets/components/jre-21 - workflow_conclusion: success - repo: PojavLauncherTeam/android-openjdk-build-multiarch - branch: buildjre17-21 - name: jre21-pojav - - uses: gradle/actions/setup-gradle@v4 with: + validate-wrappers: false gradle-version: "8.11" - - name: Build JRE JAR files + - name: Run scripts run: | ./scripts/languagelist_updater.sh - mkdir out - # Build JRE JAR files (security manager, etc...) - gradle :jre_lwjgl3glfw:build --no-daemon - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' + - name: Build Release .apk + run: | + # Build the launcher + gradle :app_pojavlauncher:assembleRelease + mv app_pojavlauncher/build/outputs/apk/release/app_pojavlauncher-release.apk out/app-release.apk - - name: Build Google Play .aab - if: github.repository_owner == 'PojavLauncherTeam' && github.ref_name == 'v3_openjdk' + - name: Gen md5sums run: | - gradle :app_pojavlauncher:bundleGplay - mv app_pojavlauncher/build/outputs/bundle/gplay/app_pojavlauncher-gplay.aab out/app-gplay.aab + md5sum out/app-release.apk > out/app-release.md5 + + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: app-release + path: out/app-release.* - name: Build Debug .apk run: | @@ -98,44 +77,31 @@ jobs: gradle :app_pojavlauncher:assembleDebug mv app_pojavlauncher/build/outputs/apk/debug/app_pojavlauncher-debug.apk out/app-debug.apk - - name: Build APK without runtime - run: | - rm -r app_pojavlauncher/src/main/assets/components/jre - rm -r app_pojavlauncher/src/main/assets/components/jre-new - rm -r app_pojavlauncher/src/main/assets/components/jre-21 - gradle assembleDebug - mv app_pojavlauncher/build/outputs/apk/debug/app_pojavlauncher-debug.apk out/app-debug-noruntime.apk - name: Gen md5sums run: | md5sum out/app-debug.apk > out/app-debug.md5 - md5sum out/app-debug-noruntime.apk > out/app-debug-noruntime.md5 - - name: Upload APK + - name: Upload debug APK uses: actions/upload-artifact@v4 with: - name: app-debug + name: app-debug (recommended) path: out/app-debug.* - - name: Upload AAB - uses: actions/upload-artifact@v4 - with: - name: app-release-aab - path: out/app-gplay.aab - - - name: Upload onto the Google Play testing track - continue-on-error: true - if: github.repository_owner == 'PojavLauncherTeam' && github.ref_name == 'v3_openjdk' - uses: r0adkll/upload-google-play@v1.1.2 - with: - serviceAccountJsonPlainText: ${{ secrets.GPLAY_SERVICE_JSON }} - packageName: net.kdt.pojavlaunch - releaseFiles: out/app-gplay.aab - track: internal - inAppUpdatePriority: 5 + - name: Build APK without runtime + run: | + rm -rf app_pojavlauncher/src/main/assets/components/jre + rm -rf app_pojavlauncher/src/main/assets/components/jre-new + rm -rf app_pojavlauncher/src/main/assets/components/jre-21 + rm -rf app_pojavlauncher/src/main/assets/components/jre-25 + ./gradlew assembleDebug + mv app_pojavlauncher/build/outputs/apk/debug/app_pojavlauncher-debug.apk out/app-debug-noruntime.apk + + - name: Gen md5sums + run: | + md5sum out/app-debug-noruntime.apk > out/app-debug-noruntime.md5 - name: Upload APK (without runtime) uses: actions/upload-artifact@v4 with: name: app-debug-noruntime path: out/app-debug-noruntime.* - diff --git a/.gitignore b/.gitignore index 9fb5d26a8a..2b4183e14d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ app_pojavlauncher/.cxx/ .vs/ /curseforge_key.txt /app_pojavlauncher/libs/ltw-release.aar +/jre_lwjgl3glfw/gradle/ +/jre_lwjgl3glfw/**/build/ +/jre_lwjgl3glfw/**/.gradle/ +/app_pojavlauncher/curseforge_key.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..912b8fdd3e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "MobileGlues"] + path = MobileGlues + url = https://github.com/MobileGL-Dev/MobileGlues.git +[submodule "SDL"] + path = app_pojavlauncher/src/main/jni/SDL + url = https://github.com/libsdl-org/SDL.git +[submodule "sdl2-compat"] + path = app_pojavlauncher/src/main/jni/sdl2-compat + url = https://github.com/libsdl-org/sdl2-compat.git diff --git a/GPLAY_PRIVACY_POLICY.md b/GPLAY_PRIVACY_POLICY.md new file mode 100644 index 0000000000..e581b25629 --- /dev/null +++ b/GPLAY_PRIVACY_POLICY.md @@ -0,0 +1,5 @@ +# MojoLauncher Privacy Policy +1. This app does not send any sensitive data, and uses network only for downloading game resources, mods or other content. +2. This app does not collect any sensitive data while you are running the game. +3. Some sensitive data is stored in crash reports after the game crashes, but it's not shared to the developer or any third parties +4. MojoLauncher developers reserve the right to update this privacy policy without prior notification. diff --git a/MobileGlues b/MobileGlues new file mode 160000 index 0000000000..694dfc2dc2 --- /dev/null +++ b/MobileGlues @@ -0,0 +1 @@ +Subproject commit 694dfc2dc21b02eca020473fbff4e589f3045e2a diff --git a/PRIVACY_POLICY_ru.md b/PRIVACY_POLICY_ru.md new file mode 100644 index 0000000000..69cc4442ab --- /dev/null +++ b/PRIVACY_POLICY_ru.md @@ -0,0 +1,5 @@ +# Политика конфиденциальности MojoLauncher +1. Данное приложение не отправляет данные о пользователе, и использует сеть только для загрузки ресурсов игры, модификаций и другого контента +2. Во время работы игры приложение не собирает данные о пользователе. +3. Данные пользователя могут быть записаны в хранилище приложения при сбое, но эти данные не отправляются разработчику или третьим лицам +4. Разработчики MojoLauncher оставляют за собой право изменить эту политику конфиденциальности в любое время без предварительного оповещения. \ No newline at end of file diff --git a/README.md b/README.md index 0acec9fd93..64c73de741 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,142 @@ -

PojavLauncher

+

Copper Launcher

+ +Copper logo + +

+ GitHub commit activity + Platform + Min SDK + Android CI + Crowdin + Discord + GitHub + CurseForge + Modrinth User + Modrinth Org +

+

+ +Copper is a fork of [Amethyst](https://github.com/AngelAuraMC/Amethyst-Android) that allows you to play Minecraft: Java Edition on your Android devices. + +## Table of Contents + +* [Introduction](#introduction) +* [Getting Copper](#getting-copper) +* [Building](#building) + * [Quick Build (Recommended)](#quick-build-recommended) + * [Detailed Build](#detailed-build) +* [Current Status](#current-status) +* [Known Issues](#known-issues) +* [FAQ](#faq) +* [Contributing](#contributing) +* [Support](#support) +* [License](#license) +* [Credits & Dependencies](#credits--dependencies) +* [Roadmap](#roadmap) + +## Introduction + +* Copper is a Minecraft: Java Edition launcher for Android based on [Boardwalk](https://github.com/zhuowei/Boardwalk) and [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher) +* This launcher can launch almost all available Minecraft versions ranging from rd-132211 to 1.21 snapshots (including Combat Test versions) +* Modding via Forge and Fabric are also supported. +* This repository contains source code for Android. + +## Getting Copper + +You can get Copper via three methods: + +1. **Nightly** Download the latest artifact from [Nightly.link](https://nightly.link/CopperLauncher/Copper-Android/workflows/android/v3_openjdk?preview) +2. **Releases:** Download the latest prebuilt app [from Releases](https://github.com/CopperLauncher/Copper-android/releases/) +3. **Build from Source:** Follow the [building instructions](#building) below. -PojavLauncher logo +## Building -[![Android CI](https://github.com/PojavLauncherTeam/PojavLauncher/workflows/Android%20CI/badge.svg)](https://github.com/PojavLauncherTeam/PojavLauncher/actions) -[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/PojavLauncherTeam/PojavLauncher)](https://github.com/PojavLauncherTeam/PojavLauncher/actions) -[![Crowdin](https://badges.crowdin.net/pojavlauncher/localized.svg)](https://crowdin.com/project/pojavlauncher) -[![Discord](https://img.shields.io/discord/724163890803638273.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.com/invite/aenk3EUvER) -[![Twitter Follow](https://img.shields.io/twitter/follow/plaunchteam?color=blue&style=flat-square)](https://twitter.com/PLaunchTeam) +### Quick Build (Recommended) -* From [Boardwalk](https://github.com/zhuowei/Boardwalk)'s ashes here comes PojavLauncher! +The easiest way to build Copper is to use the pre-built JREs provided by our CI. -* PojavLauncher is a launcher that allows you to play Minecraft: Java Edition on your Android device! +1. Clone the repository: `git clone --recursive https://github.com/CopperLauncher/CopperLauncher.git` +2. Build the launcher: `./gradlew :app_pojavlauncher:assembleDebug` (Use `gradlew.bat` on Windows) -* It can run almost every version of Minecraft, allowing you to use .jar only installers to install modloaders such as [Forge](https://files.minecraftforge.net/) and [Fabric](http://fabricmc.net/), mods like [OptiFine](https://optifine.net) and [LabyMod](https://www.labymod.net/en), as well as hack clients like [Wurst](https://www.wurstclient.net/), and much more! +The built APK will be located in `app_pojavlauncher/build/outputs/apk/debug/`. -* For more details [check out our wiki](https://pojavlauncherteam.github.io/) -## Some notes to start with -- We do not have an official TikTok account. No one from the dev team makes TikTok videos. -- The official Twitter for PojavLauncher is [@PLaunchTeam](https://twitter.com/PLaunchTeam). Any others (most notably @PojavLauncher) are fake. +### Detailed Build -## Navigation -- [Introduction](#introduction) -- [Getting PojavLauncher](#getting-pojavlauncher) -- [Building](#building) -- [Current status](#current-status) -- [License](#license) -- [Contributing](#contributing) -- [Credits & Third party components and their licenses](#credits--third-party-components-and-their-licenses-if-available) +If you need more control over the build process, follow these steps: -## Introduction -* PojavLauncher is a Minecraft: Java Edition launcher for Android and iOS based on [Boardwalk](https://github.com/zhuowei/Boardwalk). -* This launcher can launch almost all available Minecraft versions ranging from rd-132211 to 1.21 snapshots (including Combat Test versions). -* Modding via Forge and Fabric are also supported. -* This repository contains source code for Android. -* For iOS/iPadOS, check out [PojavLauncher_iOS](https://github.com/PojavLauncherTeam/PojavLauncher_iOS). +1. **Java Runtime Environment (JRE):** Download the `jre8-pojav` artifact from AngelAuraMCs [CI auto builds](https://github.com/AngelAuraMC/openjdk-build-multiarch/actions). This package contains pre-built JREs for all supported architectures. If you need to build the JRE yourself, follow the instructions in the [android-openjdk-build-multiarch](https://github.com/AngelAuraMC/openjdk-build-multiarch) repository. -## Getting PojavLauncher +2. **LWJGL:** The build instructions for the custom LWJGL are available over the [LWJGL repository](https://github.com/AngelAuraMC/lwjgl3). -You can get PojavLauncher via three methods: +3. **Language List:** Because languages are auto-added by Crowdin, you need to run the language list generator before building. In the project directory, run: + * Linux/macOS: + ```bash + chmod +x scripts/languagelist_updater.sh + bash scripts/languagelist_updater.sh + ``` + * Windows: + ```batch + scripts\languagelist_updater.bat + ``` -1. You can get the prebuilt app from [stable releases](https://github.com/PojavLauncherTeam/PojavLauncher/releases) or [automatic builds](https://github.com/PojavLauncherTeam/PojavLauncher/actions). +4. **Build GLFW stub:** `./gradlew :jre_lwjgl3glfw:build` -2. You can get it from Google Play by clicking on this badge: -[![Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=net.kdt.pojavlaunch) +5. **Build the launcher:** `./gradlew :app_pojavlauncher:assembleDebug` (Replace `gradlew` with `gradlew.bat` on Windows). + +## Current Status + +* [x] New UI +* [x] Bug Fixes +* [ ] Fix GL4ES and KW in older versions +* [ ] Add More Renders -3. You can [build](#building) from source. -## Building -If you want to build from source code, follow the steps below. -### Java Runtime Environment (JRE) -- JRE for Android is [here](https://github.com/PojavLauncherTeam/openjdk-multiarch-jdk8u), and the build script is [here](https://github.com/PojavLauncherTeam/android-openjdk-build-multiarch). -- Follow build instruction on build script [README.md](https://github.com/PojavLauncherTeam/android-openjdk-build-multiarch/blob/buildjre8/README.md). -- You can also get [CI auto builds](https://github.com/PojavLauncherTeam/android-openjdk-build-multiarch/actions) if you are lazy or are failing to build it for some reason. -* Either get the `jre8-pojav` artifact from auto builds, or split all artifacts by yourself:
- - Get JREs for all of 4 supported architectures (arm, arm64, x86, x86_64)
- - Split JRE into parts:
- Platform-independent: .jar files, libraries, configs, etc...
- Platform-dependent: .so files, etc...
- - Create:
- A file named `universal.tar.xz` with all platform-independent files
- 4 files named `bin-.tar.xz` with all platform-dependent files per-architecture
- - Put these in the `assets/components/jre/` folder
- - (If needed) update the Version file with the current date
- -### LWJGL -The build instructions for the custom LWJGL are available over the [LWJGL repository](https://github.com/PojavLauncherTeam/lwjgl3) - -### The Launcher -- Because languages are auto-added by Crowdin, you need to run the language list generator before building. In the project directory, run: - -* On Linux, Mac OS: -``` -chmod +x scripts/languagelist_updater.sh -bash scripts/languagelist_updater.sh -``` -* On Windows: -``` -scripts\languagelist_updater.bat -``` -Then, run these commands ~~or build using Android Studio~~. - -* Build GLFW stub: -``` -./gradlew :jre_lwjgl3glfw:build -``` -* Build the launcher -``` -./gradlew :app_pojavlauncher:assembleDebug -``` -(Replace `gradlew` with `gradlew.bat` if you are building on Windows). - -## Current status -- [x] ~~OpenJDK 9 Mobile port: ARM32, ARM64, x86, x86_64.~~ Replaced by JRE8. -- [x] OpenJDK 8 Mobile port: ARM32, ARM64, x86, x86_64 -- [x] OpenJDK 17 Mobile port: ARM32, ARM64, x86, x86_64 -- [x] Headless mod installer -- [x] Mod installer with GUI. Used `Caciocavallo` project for AWT without X11. -- [x] OpenGL in OpenJDK environment -- [x] OpenAL (works on most devices) -- [x] Support for Minecraft 1.12.2 and below. Used [lwjglx](https://github.com/PojavLauncherTeam/lwjglx), a LWJGL2 compatibility layer for LWJGL3. -- [x] Support for Minecraft 1.13 and above. Used [GLFW stub](https://github.com/PojavLauncherTeam/lwjgl3-glfw-java). -- [x] Support for Minecraft 1.17 (22w13a to be exact) and above. Used [Holy GL4ES](https://github.com/PojavLauncherTeam/gl4es-114-extra) -- [x] Game surface zooming. -- [x] New input pipe rewritten to native code to boost performance. -- [x] Rewritten entire controls system (thanks to @Mathias-Boulay) -- [ ] More to come! ## Known Issues -- Controller mods aren't working. -- Random crashes could happen very often on Android 5.x when loading the game or joining a world. -- With big modpacks textures could be messed up -- Probably more, that's why we have a bug tracker ;) + +See Copper's [issue tracker](https://github.com/CopperLauncher/Copper-Android/issues) for a list of known issues and their current status. + +## Support + +For support, please join our [Discord server](https://dsc.gg/Copper-launcher) ## License -- PojavLauncher is licensed under [GNU LGPLv3](https://github.com/PojavLauncherTeam/PojavLauncher/blob/v3_openjdk/LICENSE). - -## Contributing -Contributions are welcome! We welcome any type of contribution, not only code. For example, you can help the wiki shape up. You can help the [translation](https://crowdin.com/project/pojavlauncher) too! - - -Any code change to this repository (or iOS) should be submitted as a pull request. The description should explain what the code does and give steps to execute it. - -## Credits & Third party components and their licenses (if available) -- [Boardwalk](https://github.com/zhuowei/Boardwalk) (JVM Launcher): Unknown License/[Apache License 2.0](https://github.com/zhuowei/Boardwalk/blob/master/LICENSE) or GNU GPLv2. -- Android Support Libraries: [Apache License 2.0](https://android.googlesource.com/platform/prebuilts/maven_repo/android/+/master/NOTICE.txt). -- [GL4ES](https://github.com/PojavLauncherTeam/gl4es): [MIT License](https://github.com/ptitSeb/gl4es/blob/master/LICENSE).
-- [OpenJDK](https://github.com/PojavLauncherTeam/openjdk-multiarch-jdk8u): [GNU GPLv2 License](https://openjdk.java.net/legal/gplv2+ce.html).
-- [LWJGL3](https://github.com/PojavLauncherTeam/lwjgl3): [BSD-3 License](https://github.com/LWJGL/lwjgl3/blob/master/LICENSE.md). -- [LWJGLX](https://github.com/PojavLauncherTeam/lwjglx) (LWJGL2 API compatibility layer for LWJGL3): unknown license.
-- [Mesa 3D Graphics Library](https://gitlab.freedesktop.org/mesa/mesa): [MIT License](https://docs.mesa3d.org/license.html). -- [pro-grade](https://github.com/pro-grade/pro-grade) (Java sandboxing security manager): [Apache License 2.0](https://github.com/pro-grade/pro-grade/blob/master/LICENSE.txt). -- [bhook](https://github.com/bytedance/bhook) (Used for exit code trapping): [MIT license](https://github.com/bytedance/bhook/blob/main/LICENSE). -- [libepoxy](https://github.com/anholt/libepoxy): [MIT License](https://github.com/anholt/libepoxy/blob/master/COPYING). -- [virglrenderer](https://github.com/PojavLauncherTeam/virglrenderer): [MIT License](https://gitlab.freedesktop.org/virgl/virglrenderer/-/blob/master/COPYING). -- Thanks to [MCHeads](https://mc-heads.net) for providing Minecraft avatars. + +Copper is licensed under [GNU LGPLv3](https://github.com/CopperLauncher/Copper-Android/blob/v3_openjdk/LICENSE). + +## Credits & Dependencies + +* [Boardwalk](https://github.com/zhuowei/Boardwalk) (JVM Launcher): Unknown License/[Apache License 2.0](https://github.com/zhuowei/Boardwalk/blob/master/LICENSE) or GNU GPLv2. +* [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher): [GLGPL](https://github.com/PojavLauncherTeam/PojavLauncher/blob/v3_openjdk/LICENSE) +* [Amethyst Launcher](https://github.com/AngelAuraMC/Amethyst-Android/): [LGPL-3.0 license](https://github.com/AngelAuraMC/Amethyst-Android/blob/v3_openjdk/LICENSE) +* [MojoLauncher](https://github.com/MojoLauncher/MojoLauncher/): [LGPL-3.0 license](https://github.com/AngelAuraMC/Amethyst-Android/blob/v3_openjdk/LICENSE) +* Android Support Libraries: [Apache License 2.0](https://android.googlesource.com/platform/prebuilts/maven_repo/android/+/master/NOTICE.txt). +* [GL4ES](https://github.com/AngelAuraMC/gl4es): [MIT License](https://github.com/ptitSeb/gl4es/blob/master/LICENSE). +* [MobileGlues](https://github.com/MobileGL-Dev/MobileGlues): [LGPL-2.1 License](https://github.com/MobileGL-Dev/MobileGlues/blob/dev-es/LICENSE). +* [ANGLE](https://chromium.googlesource.com/angle/angle): [All Rights Reserved](app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE). +* [OpenJDK](https://github.com/AngelAuraMC/openjdk-multiarch-jdk8u): [GNU GPLv2 License](https://openjdk.java.net/legal/gplv2+ce.html). +* [LWJGL3](https://github.com/AngelAuraMC/lwjgl3): [BSD-3 License](https://github.com/LWJGL/lwjgl3/blob/master/LICENSE.md). +* [LWJGLX](https://github.com/AngelAuraMC/lwjglx) (LWJGL2 API compatibility layer for LWJGL3): unknown license. +* [Mesa 3D Graphics Library](https://gitlab.freedesktop.org/mesa/mesa): [MIT License](https://docs.mesa3d.org/license.html). +* [pro-grade](https://github.com/pro-grade/pro-grade) (Java sandboxing security manager): [Apache License 2.0](https://github.com/pro-grade/pro-grade/blob/master/LICENSE.txt). +* [bhook](https://github.com/bytedance/bhook) (Used for exit code trapping): [MIT license](https://github.com/bytedance/bhook/blob/main/LICENSE). +* [libepoxy](https://github.com/anholt/libepoxy): [MIT License](https://github.com/anholt/libepoxy/blob/master/COPYING). +* [virglrenderer](https://github.com/AngelAuraMC/virglrenderer): [MIT License](https://gitlab.freedesktop.org/virgl/virglrenderer/-/blob/master/COPYING). +* [OpenAL-Soft](https://github.com/kcat/openal-soft): [GNU GPLv2](app_pojavlauncher/src/main/assets/licenses/OPENAL-SOFT_GPL2) + * [oboe](https://github.com/google/oboe): [Apache License 2.0](app_pojavlauncher/src/main/assets/licenses/OBOE_APACHE2). + * [pfffft](https://bitbucket.org/jpommier/pffft/src/master/): [ARR](app_pojavlauncher/src/main/assets/licenses/PFFFT_LICENSE) +* [SDL3](https://github.com/libsdl-org/SDL): [zlib License](https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt) +* [sdl2-compat](https://github.com/libsdl-org/sdl2-compat): [zlib License](https://github.com/libsdl-org/sdl2-compat/blob/main/LICENSE.txt) +* Thanks to [MCHeads](https://mc-heads.net) for providing Minecraft avatars. + +## Roadmap + +We are currently focusing on: + +* Exploring new rendering technologies. + +Future plans include: + +* Improving stability and performance. +* Enhancing the mod installation experience. + +We welcome community feedback and suggestions for our roadmap. Please feel free to open a feature request in our [issue tracker](https://github.com/CopperLauncher/Copper-Android/issues). diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 0000000000..31a47c41ef --- /dev/null +++ b/README_RU.md @@ -0,0 +1,86 @@ +

MojoLauncher

+ +Readme in English + +MojoLauncher logo + +[![Android CI](https://github.com/MojoLauncher/MojoLauncher/workflows/Android%20CI/badge.svg)](https://github.com/MojoLauncher/MojoLauncher/actions) +[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/MojoLauncher/MojoLauncher)](https://github.com/MojoLauncher/MojoLauncher/actions) +[![Crowdin](https://badges.crowdin.net/pojavlauncher/localized.svg)](https://crowdin.com/project/pojavlauncher) +[![Discord](https://img.shields.io/discord/1365346109131722753.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/VHdwQFsaGX) + +* MojoLauncher это лаунчер, основанный на [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher), позволяющий играть в Minecraft: Java Edition на устройствах Android! + +* Он может запускать почти любую версию Minecraft, позволяя устанваливать через .jar загрузчики модов такие как [Forge](https://files.minecraftforge.net/) и [Fabric](http://fabricmc.net/), и моды по типу [OptiFine](https://optifine.net). + +## Навигация +- [Введение](#introduction) +- [Как получить MojoLauncher](#getting-mojolauncher) +- [Сборка](#building) +- [Текущие планы](#current-roadmap) +- [Лицензия](#license) +- [Внести свой вклад в проект](#contributing) +- [Благодарности & Сторонние компоненты и их лицензии](#credits--third-party-components-and-their-licenses-if-available) + +## Введение +* MojoLauncher это лаунчер Minecraft: Java Edition для Android основанный на [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher) +* Лаунчер может запускать почти все доступные версии Minecraft в диапазоне от rd-132211 до снапшотов 1.21 (включая версии Combat Test). +* Моддинг через Forge и Fabric так же поддерживается. + +## Как получить MojoLauncher + +Получить копию MojoLauncher можно тремя способами: + +1. Можно скачать готовый билд с раздела [автоматических сборок](https://github.com/MojoLauncher/MojoLauncher/actions). + +2. Можно скачать с Google Play нажав на кнопку ниже: +[![Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=git.artdeell.mojo) + +3. Можно [собрать](#building) с исходного кода. +## Сборка +* Скомпилируйте лаунчер (все необходимые компоненты скачаются автоматически) +``` +./gradlew :app_pojavlauncher:assembleDebug +``` +(Замените `./gradlew` на `.\gradlew.bat` если вы компилируете на Windows). + +## Текущие планы +- [x] Система инстансов на замену профилям версий +- [x] Поддержка 1.21.5 "из коробки" +- [x] Импорт mrpack/CurseForge zip +- [ ] LTW: исправить проблемы с Create +- [ ] LTW: добавить поддержку Compute расширений +- [ ] LTW: использовать форматы с поддержкой Color Attachment для объектов фреймбуфера (FBO) +- [ ] Менеджер модов/модпаков +- [ ] Импорт инстансов формата MultiMC +- [ ] Patch-on-dlopen для нативных библиотек модов +- [ ] Заменить Holy-GL4ES 1.1.5 на KW (нужно уточнить требования) + +## Известные проблемы +- Некоторые физические мышки могут иметь низкую скорость курсора +- На Holy GL4ES большие алиасы текстур могут быть искажены (из-за чего в некоторых модпаках могут быть растянутые или излишне квадратные текстуры) +- Скорее всего есть ещё, поэтому у нас есть баг-трекер ;) + +## Лицензия +- MojoLauncher лицензирован под [GNU LGPLv3](https://github.com/MojoLauncher/MojoLauncher/blob/v3_openjdk/LICENSE). + +## Внести свой вклад в проект +Мы привествуем желающих внести свой вклад в проект! Нам не помешает любая помощь, не только код. Например, вы можете помочь в разработке и формировании вики. Вы так же можете помочь [перевести проект](https://crowdin.com/project/pojavlauncher) на ваш язык! + + +Любые изменения в коде этого репозитория должны быть отправлены в виде pull request-а. Описание должно объяснять что делает код и предоставлять шаги для его запуска. + +## Благодарности & Сторонние компоненты и их лицензии (если таковые имеются) +- [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher): [GNU LGPLv3 License](https://github.com/PojavLauncherTeam/PojavLauncher/blob/v3_openjdk/LICENSE) +- [Boardwalk](https://github.com/zhuowei/Boardwalk) (JVM Лаунчер): Неизвестная Лицензия/[Apache License 2.0](https://github.com/zhuowei/Boardwalk/blob/master/LICENSE) или GNU GPLv2. +- Android Support Libraries: [Apache License 2.0](https://android.googlesource.com/platform/prebuilts/maven_repo/android/+/master/NOTICE.txt). +- [GL4ES](https://github.com/PojavLauncherTeam/gl4es): [MIT License](https://github.com/ptitSeb/gl4es/blob/master/LICENSE).
+- [OpenJDK](https://github.com/PojavLauncherTeam/openjdk-multiarch-jdk8u): [GNU GPLv2 License](https://openjdk.java.net/legal/gplv2+ce.html).
+- [LWJGL3](https://github.com/MojoLauncher/lwjgl3): [BSD-3 License](https://github.com/LWJGL/lwjgl3/blob/master/LICENSE.md). +- [Mesa 3D Graphics Library](https://gitlab.freedesktop.org/mesa/mesa): [MIT License](https://docs.mesa3d.org/license.html). +- [pro-grade](https://github.com/pro-grade/pro-grade) (Менеджер контейнеризации Java): [Apache License 2.0](https://github.com/pro-grade/pro-grade/blob/master/LICENSE.txt). +- [bhook](https://github.com/bytedance/bhook) (Используется для получения кода ошибки): [MIT license](https://github.com/bytedance/bhook/blob/main/LICENSE). +- [Authlib-Injector](https://github.com/yushijinhun/authlib-injector) (Используется для авторизации через ely.by): [AGPL-3.0](https://github.com/yushijinhun/authlib-injector/blob/develop/LICENSE). +- [alsoft](https://github.com/kcat/openal-soft/) (Библиотека вывода звука): [GNU LIBRARY GENERAL PUBLIC LICENSE](https://github.com/kcat/openal-soft/blob/master/COPYING) и [modified PFFFT](https://github.com/kcat/openal-soft/blob/master/LICENSE-pffft). +- [oboe](https://github.com/google/oboe): [Apache License 2.0](https://github.com/google/oboe/blob/main/LICENSE). +- Отдельная благодарность к [Mineskin](https://mineskin.eu/) за предоставление аватаров Minecraft. diff --git a/Task b/Task new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_pojavlauncher/aamc_upload.jks b/app_pojavlauncher/aamc_upload.jks new file mode 100644 index 0000000000..1033b27ab7 Binary files /dev/null and b/app_pojavlauncher/aamc_upload.jks differ diff --git a/app_pojavlauncher/build.gradle b/app_pojavlauncher/build.gradle index aafdd1efb9..fcb86d7ae7 100644 --- a/app_pojavlauncher/build.gradle +++ b/app_pojavlauncher/build.gradle @@ -4,18 +4,10 @@ plugins { static def getDate() { return new Date().format('yyyyMMdd') } -static def getDateSeconds() { - if (System.getenv("GITHUB_ACTIONS") == "true") { - return 9934841 + Integer.parseInt(System.getenv("GITHUB_RUN_NUMBER")) - } else { - return 172005 - } -} - def getVersionName = { // Get the last version tag, as well as the short head of the last commit ByteArrayOutputStream TAG = new ByteArrayOutputStream() - ByteArrayOutputStream BRANCH = new ByteArrayOutputStream() + String semVer = "1.1.5" // Used by the fallback for github actions ByteArrayOutputStream TAG_PART_COMMIT = new ByteArrayOutputStream() String TAG_STRING @@ -51,28 +43,18 @@ def getVersionName = { return ("LOCAL-" + "${getDate()}") }else { // Used by github actions - TAG_STRING = 'gladiolus-' + "${getDate()}" + "-" + TAG_PART_COMMIT.toString().trim() + TAG_STRING = 'copper-legacy-' + "${getDate()}" + "-" + TAG_PART_COMMIT.toString().trim() } } - exec { - try { - commandLine 'git', 'branch', '--show-current' - ignoreExitValue true - standardOutput = BRANCH - } catch (Exception e) { - e.printStackTrace(); - } - } - - return TAG_STRING.trim().replace("-g", "-") + "-" + BRANCH.toString().trim() + return TAG_STRING.trim().replace("-g", "-") + "-" + semVer } def getCFApiKey = { String key = System.getenv("CURSEFORGE_API_KEY"); if(key != null) return key; - File curseforgeKeyFile = new File("./curseforge_key.txt"); + File curseforgeKeyFile = new File("$projectDir/curseforge_key.txt"); if(curseforgeKeyFile.canRead() && curseforgeKeyFile.isFile()) { return curseforgeKeyFile.text; } @@ -103,6 +85,12 @@ android { keyAlias "androiddebugkey" keyPassword "android" } + customRelease { + storeFile file("aamc_upload.jks") + storePassword System.getenv("GPLAY_KEYSTORE_PASSWORD") + keyAlias "android_github_release" + keyPassword System.getenv("GPLAY_KEYSTORE_PASSWORD") + } googlePlayBuild { storeFile file("upload.jks") storePassword System.getenv("GPLAY_KEYSTORE_PASSWORD") @@ -112,13 +100,19 @@ android { } defaultConfig { - applicationId "net.kdt.pojavlaunch" + applicationId "com.maxjubayeryt.copper" minSdkVersion 21 targetSdkVersion 34 - versionCode getDateSeconds() + versionCode 10000000 versionName getVersionName() multiDexEnabled true //important resValue 'string', 'curseforge_api_key', getCFApiKey() + externalNativeBuild { + ndkBuild { + // Fix for Windows command line length limit + arguments "APP_SHORT_COMMANDS=true" + } + } } buildTypes { @@ -128,9 +122,11 @@ android { shrinkResources false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' signingConfig signingConfigs.customDebug - resValue 'string', 'application_package', 'net.kdt.pojavlaunch.debug' - resValue 'string', 'storageProviderAuthorities', 'net.kdt.pojavlaunch.scoped.gamefolder.debug' - resValue 'string', 'shareProviderAuthority', 'net.kdt.pojavlaunch.scoped.controlfolder.debug' + resValue "string", "app_name", "Copper (Debug)" + resValue "string", "app_short_name", "Copper (Debug)" + resValue 'string', 'application_package', 'com.maxjubayeryt.copper.debug' + resValue 'string', 'storageProviderAuthorities', 'com.maxjubayeryt.copper.scoped.gamefolder.debug' + resValue 'string', 'shareProviderAuthority', 'com.maxjubayeryt.copper.scoped.controlfolder.debug' } proguard { initWith debug @@ -146,19 +142,23 @@ android { // Don't set to true or java.awt will be a.a or something similar. minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.customRelease // defaultConfig already set // multiDexEnabled = true // debuggable = true - resValue 'string', 'storageProviderAuthorities', 'net.kdt.pojavlaunch.scoped.gamefolder' - resValue 'string', 'application_package', 'net.kdt.pojavlaunch' + resValue "string", "app_name", "Copper" + resValue "string", "app_short_name", "Copper" + resValue 'string', 'storageProviderAuthorities', 'com.maxjubayeryt.copper.gamefolder' + resValue 'string', 'application_package', 'com.maxjubayeryt.copper' } gplay { initWith release signingConfig signingConfigs.googlePlayBuild + matchingFallbacks = [ 'release' ] } } - ndkVersion = "25.2.9519653" + ndkVersion = "27.3.13750724" externalNativeBuild { ndkBuild { @@ -181,6 +181,9 @@ android { jniLibs { useLegacyPackaging = true } + doNotStrip += [ + "**/libcutils.so" + ] pickFirst '**/libbytehook.so' } @@ -194,7 +197,8 @@ android { afterEvaluate { // Explicit dependencies for which the apk relies on - tasks.mergeDebugAssets.dependsOn(":forge_installer:jar", ":arc_dns_injector:jar", ":jre_lwjgl3glfw:jar") + tasks.mergeDebugAssets.dependsOn(":forge_installer:jar", ":arc_dns_injector:jar", ":methods_injector_agent:jar", ":jre_lwjgl3glfw:jar") + tasks.mergeReleaseAssets.dependsOn(":forge_installer:jar", ":arc_dns_injector:jar", ":methods_injector_agent:jar", ":jre_lwjgl3glfw:jar") } dependencies { @@ -202,6 +206,7 @@ dependencies { implementation 'commons-codec:commons-codec:1.15' // implementation 'com.wu-man:android-bsf-api:3.1.3' implementation 'androidx.preference:preference:1.2.0' + implementation 'androidx.palette:palette:1.0.0' //implementation 'androidx.core:core:1.7.0' implementation 'androidx.drawerlayout:drawerlayout:1.2.0' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' @@ -227,5 +232,10 @@ dependencies { // implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.0' + implementation 'top.fifthlight.touchcontroller:proxy-client-android:0.0.4' + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) -} + + implementation "net.java.dev.jna:jna:5.13.0@aar" + +} \ No newline at end of file diff --git a/app_pojavlauncher/libs/HolyGL4ES-release.aar b/app_pojavlauncher/libs/HolyGL4ES-release.aar new file mode 100644 index 0000000000..bd9c13123e Binary files /dev/null and b/app_pojavlauncher/libs/HolyGL4ES-release.aar differ diff --git a/app_pojavlauncher/libs/MobileGlues-release.aar b/app_pojavlauncher/libs/MobileGlues-release.aar new file mode 100644 index 0000000000..eb09f3666c Binary files /dev/null and b/app_pojavlauncher/libs/MobileGlues-release.aar differ diff --git a/app_pojavlauncher/libs/SDL-release.aar b/app_pojavlauncher/libs/SDL-release.aar new file mode 100644 index 0000000000..68190f7c6f Binary files /dev/null and b/app_pojavlauncher/libs/SDL-release.aar differ diff --git a/app_pojavlauncher/libs/angle-release.aar b/app_pojavlauncher/libs/angle-release.aar new file mode 100644 index 0000000000..cc22015d6a Binary files /dev/null and b/app_pojavlauncher/libs/angle-release.aar differ diff --git a/app_pojavlauncher/libs/imgui-java-release.aar b/app_pojavlauncher/libs/imgui-java-release.aar new file mode 100644 index 0000000000..fd184eb7b2 Binary files /dev/null and b/app_pojavlauncher/libs/imgui-java-release.aar differ diff --git a/app_pojavlauncher/src/main/jniLibs/arm64-v8a/libopenal.so b/app_pojavlauncher/libs/kopper-zink-release.aar similarity index 63% rename from app_pojavlauncher/src/main/jniLibs/arm64-v8a/libopenal.so rename to app_pojavlauncher/libs/kopper-zink-release.aar index f66c4ff7ba..3810944fbb 100644 Binary files a/app_pojavlauncher/src/main/jniLibs/arm64-v8a/libopenal.so and b/app_pojavlauncher/libs/kopper-zink-release.aar differ diff --git a/app_pojavlauncher/src/main/jniLibs/x86_64/libopenal.so b/app_pojavlauncher/libs/lwjgl-3.3.3-natives-release.aar similarity index 63% rename from app_pojavlauncher/src/main/jniLibs/x86_64/libopenal.so rename to app_pojavlauncher/libs/lwjgl-3.3.3-natives-release.aar index 52cf2deb05..4f58d4ddee 100644 Binary files a/app_pojavlauncher/src/main/jniLibs/x86_64/libopenal.so and b/app_pojavlauncher/libs/lwjgl-3.3.3-natives-release.aar differ diff --git a/app_pojavlauncher/src/main/jniLibs/x86/libopenal.so b/app_pojavlauncher/libs/lwjgl-3.4.1-natives-release.aar similarity index 58% rename from app_pojavlauncher/src/main/jniLibs/x86/libopenal.so rename to app_pojavlauncher/libs/lwjgl-3.4.1-natives-release.aar index 7cf78857ed..2825e9b4a1 100644 Binary files a/app_pojavlauncher/src/main/jniLibs/x86/libopenal.so and b/app_pojavlauncher/libs/lwjgl-3.4.1-natives-release.aar differ diff --git a/app_pojavlauncher/libs/mesa.aar b/app_pojavlauncher/libs/mesa.aar new file mode 100644 index 0000000000..16d0740961 Binary files /dev/null and b/app_pojavlauncher/libs/mesa.aar differ diff --git a/app_pojavlauncher/libs/openal-soft-release.aar b/app_pojavlauncher/libs/openal-soft-release.aar new file mode 100644 index 0000000000..c784e86c58 Binary files /dev/null and b/app_pojavlauncher/libs/openal-soft-release.aar differ diff --git a/app_pojavlauncher/libs/shaderc.aar b/app_pojavlauncher/libs/shaderc.aar new file mode 100644 index 0000000000..c57944d504 Binary files /dev/null and b/app_pojavlauncher/libs/shaderc.aar differ diff --git a/app_pojavlauncher/libs/spirv-cross-natives.aar b/app_pojavlauncher/libs/spirv-cross-natives.aar new file mode 100644 index 0000000000..7f11bf60aa Binary files /dev/null and b/app_pojavlauncher/libs/spirv-cross-natives.aar differ diff --git a/app_pojavlauncher/libs/spvc.aar b/app_pojavlauncher/libs/spvc.aar new file mode 100644 index 0000000000..a35ff062be Binary files /dev/null and b/app_pojavlauncher/libs/spvc.aar differ diff --git a/app_pojavlauncher/libs/zstd-jni-release.aar b/app_pojavlauncher/libs/zstd-jni-release.aar new file mode 100644 index 0000000000..51df24946c Binary files /dev/null and b/app_pojavlauncher/libs/zstd-jni-release.aar differ diff --git a/app_pojavlauncher/mojo_pub.jks b/app_pojavlauncher/mojo_pub.jks new file mode 100644 index 0000000000..f6fd0317d0 Binary files /dev/null and b/app_pojavlauncher/mojo_pub.jks differ diff --git a/app_pojavlauncher/mojo_upload.jks b/app_pojavlauncher/mojo_upload.jks new file mode 100644 index 0000000000..29eee0f859 Binary files /dev/null and b/app_pojavlauncher/mojo_upload.jks differ diff --git a/app_pojavlauncher/src/main/AndroidManifest.xml b/app_pojavlauncher/src/main/AndroidManifest.xml index ebaf69481c..a25fb0d92e 100644 --- a/app_pojavlauncher/src/main/AndroidManifest.xml +++ b/app_pojavlauncher/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app_pojavlauncher/src/main/assets/components/security/version b/app_pojavlauncher/src/main/assets/components/security/version index 56a6051ca2..d8263ee986 100644 --- a/app_pojavlauncher/src/main/assets/components/security/version +++ b/app_pojavlauncher/src/main/assets/components/security/version @@ -1 +1 @@ -1 \ No newline at end of file +2 \ No newline at end of file diff --git a/app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE b/app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE new file mode 100644 index 0000000000..0f65fd60fd --- /dev/null +++ b/app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE @@ -0,0 +1,32 @@ +// Copyright 2018 The ANGLE Project Authors. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc. +// Ltd., nor the names of their contributors may be used to endorse +// or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. diff --git a/app_pojavlauncher/src/main/assets/pojavlauncher.png b/app_pojavlauncher/src/main/assets/pojavlauncher.png index 2dd689f69f..958747c02a 100644 Binary files a/app_pojavlauncher/src/main/assets/pojavlauncher.png and b/app_pojavlauncher/src/main/assets/pojavlauncher.png differ diff --git a/app_pojavlauncher/src/main/assets/substitutions.json b/app_pojavlauncher/src/main/assets/substitutions.json new file mode 100644 index 0000000000..605fe46f8b --- /dev/null +++ b/app_pojavlauncher/src/main/assets/substitutions.json @@ -0,0 +1,1789 @@ +{ + "libraries": { + "org.lwjgl:lwjgl-glfw:3.3.6": { + "name": "org.lwjgl:lwjgl-glfw:3.3.6", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.6/lwjgl-glfw-3.3.6.jar", + "sha1": "1875a8f239a940147bf95bd9882018d81682feb6", + "size": 140343, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-glfw-3.3.6.jar" + } + } + }, + "org.lwjgl:lwjgl:3.3.6": { + "name": "org.lwjgl:lwjgl:3.3.6", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.6/lwjgl-3.3.6.jar", + "sha1": "7282625e5408cda7b48ed242be08c27d9e973ff9", + "size": 831693, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-3.3.6.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.3.6/lwjgl-natives-linux-arm32-3.3.6.jar", + "sha1": "f667e9de87fe70edad031aaebb61d0dd6b940987", + "size": 85152, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-natives-linux-arm32-3.3.6.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.3.6/lwjgl-natives-linux-arm64-3.3.6.jar", + "sha1": "5a5c0cc8be2b62ad780f2d641efb4a76d17d79ab", + "size": 105657, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-natives-linux-arm64-3.3.6.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.3.6/lwjgl-natives-linux-x86-3.3.6.jar", + "sha1": "746b1f4c4f9e9c48ca832c2b5afdfe7b5e365c8a", + "size": 122853, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-natives-linux-x86-3.3.6.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.3.6/lwjgl-natives-linux-3.3.6.jar", + "sha1": "aa942d916f97cb30758258e437df1874130767c1", + "size": 107975, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-natives-linux-3.3.6.jar" + } + } + } + }, + "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw": { + "name": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl/2.9.4-glfw/lwjgl-2.9.4-glfw.jar", + "sha1": "1e6fc1766202f6ec929225013cca0937820ce6fb", + "size": 1050953, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl.jar" + } + } + }, + "org.lwjgl:lwjgl-glfw:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-glfw:3.3.1": { + "name": "org.lwjgl:lwjgl-glfw:3.3.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1.jar", + "sha1": "21a1a9c6282fddaf240ed242250bfff134b0da49", + "size": 130135, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-glfw-3.3.1.jar" + } + } + }, + "org.lwjgl:lwjgl-glfw:3.3.2": { + "name": "org.lwjgl:lwjgl-glfw:3.3.2", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2.jar", + "sha1": "4911b2d8cbffcabead74a1f227494f139996cac4", + "size": 130229, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-glfw-3.3.2.jar" + } + } + }, + "org.lwjgl:lwjgl-glfw:3.3.3": { + "name": "org.lwjgl:lwjgl-glfw:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar", + "sha1": "cadd3548ed3a6c0150a3a8e1a2a7737719b6d775", + "size": 130079, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-glfw-3.3.3.jar" + } + } + }, + "org.lwjgl:lwjgl-glfw:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-openal:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-openal:3.2.3": { + "name": "org.lwjgl:lwjgl-openal:3.2.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.2.3/lwjgl-openal-3.2.3.jar", + "sha1": "4c5f6ba877fe9c24b613ff08f4504389f0ba6ed6", + "size": 79926, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-openal-3.2.3.jar" + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl:3.3.1": { + "name": "org.lwjgl:lwjgl:3.3.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "345b2478ab87a20192d733d41f12f9f39cd43507", + "size": 719786, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-3.3.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-natives-linux-arm32-3.3.1.jar", + "sha1": "5ffac2796ea566f30fa4b9bd5e4e55d1451a75dc", + "size": 74294, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-natives-linux-arm32-3.3.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-natives-linux-arm64-3.3.1.jar", + "sha1": "9cf245dc940d6435e3fb4274c01d5d2547569b2b", + "size": 92193, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-natives-linux-arm64-3.3.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-natives-linux-x86-3.3.1.jar", + "sha1": "067946c15bab2867d1603fac1ee4d9a76777fca8", + "size": 105831, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-natives-linux-x86-3.3.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-natives-linux-3.3.1.jar", + "sha1": "a81785dd78af90c9bcff8e6e74d307846c11c32b", + "size": 94590, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-natives-linux-3.3.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-freetype:3.4.1": { + "name": "org.lwjgl:lwjgl-freetype:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.4.1/lwjgl-freetype-3.4.1.jar", + "sha1": "8e720a2a666eba0037af0994318c18f4d9217b29", + "size": 465792, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-freetype-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl:3.3.2": { + "name": "org.lwjgl:lwjgl:3.3.2", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar", + "sha1": "c3509d77460f7bae720dc1e4f62b4d692af3fdb7", + "size": 790735, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-3.3.2.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-natives-linux-arm32-3.3.2.jar", + "sha1": "8035f5fd3d922bd475e298d9e254374e328a4d68", + "size": 81042, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-natives-linux-arm32-3.3.2.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-natives-linux-arm64-3.3.2.jar", + "sha1": "25ca8f84187f8ddc88f1bbe6e7bd810a28062545", + "size": 99407, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-natives-linux-arm64-3.3.2.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-natives-linux-x86-3.3.2.jar", + "sha1": "2f6570e07b9fdb2e682c6826438a65ab22452e76", + "size": 114572, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-natives-linux-x86-3.3.2.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-natives-linux-3.3.2.jar", + "sha1": "7fed8ac152267fe9988d9356751292e874ce356c", + "size": 102364, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-natives-linux-3.3.2.jar" + } + } + } + }, + "org.lwjgl:lwjgl:3.3.3": { + "name": "org.lwjgl:lwjgl:3.3.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "cdb024f15d3515658721b830c56d08a557245b44", + "size": 792322, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-3.3.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-natives-linux-arm32-3.3.3.jar", + "sha1": "3e377c22a346d1bb986ada4cb031df7967b6b5eb", + "size": 81398, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-natives-linux-arm32-3.3.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-natives-linux-arm64-3.3.3.jar", + "sha1": "51734d79d77bd557e4e5f052dd88fb564e16f9ef", + "size": 100134, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-natives-linux-arm64-3.3.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-natives-linux-x86-3.3.3.jar", + "sha1": "251819efc105d7bd122dc1f2012e81ed7fabe4b6", + "size": 115381, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-natives-linux-x86-3.3.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-natives-linux-3.3.3.jar", + "sha1": "72496fa2cbf1b32558e12817b8c5fbb63e82f13c", + "size": 102869, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-natives-linux-3.3.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-freetype:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-shaderc:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-stb:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-vulkan:3.4.1": { + "name": "org.lwjgl:lwjgl-vulkan:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar", + "sha1": "aaad248b496cf9e2c5e93c779e6ec69f5aed2851", + "size": 8540349, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vulkan-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl-opengl:3.2.3": { + "name": "org.lwjgl:lwjgl-opengl:3.2.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.2.3/lwjgl-opengl-3.2.3.jar", + "sha1": "a6945417bdc56382cafbda07d15110aae1557c2e", + "size": 947623, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-opengl-3.2.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.2.3/lwjgl-opengl-natives-linux-arm32-3.2.3.jar", + "sha1": "f48e3de1aa9f35ddd4cf713b58c2c55606c5bdae", + "size": 47459, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-opengl-natives-linux-arm32-3.2.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.2.3/lwjgl-opengl-natives-linux-arm64-3.2.3.jar", + "sha1": "2d96fade0c7fc47836c2df1bd424246969ab3fa8", + "size": 70191, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-opengl-natives-linux-arm64-3.2.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.2.3/lwjgl-opengl-natives-linux-x86-3.2.3.jar", + "sha1": "7ea805d0c70dd0f3b3cc8b5ecdfb05ab46ae252b", + "size": 74159, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-opengl-natives-linux-x86-3.2.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.2.3/lwjgl-opengl-natives-linux-3.2.3.jar", + "sha1": "a7be712e1f4bfaf5f105593c589240fe6db14cd8", + "size": 67878, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-opengl-natives-linux-3.2.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-stb:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-tinyfd:3.2.3": { + "name": "org.lwjgl:lwjgl-tinyfd:3.2.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.2.3/lwjgl-tinyfd-3.2.3.jar", + "sha1": "38302497cc4efe43aa07e6f9c40bbde8256ed181", + "size": 6434, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-tinyfd-3.2.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.2.3/lwjgl-tinyfd-natives-linux-arm32-3.2.3.jar", + "sha1": "42f6a7171c6d0bf2092e60ff88e725d75eee181a", + "size": 41554, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-tinyfd-natives-linux-arm32-3.2.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.2.3/lwjgl-tinyfd-natives-linux-arm64-3.2.3.jar", + "sha1": "8e9c7a99d08fe6d17a751bd8c7b43fea619ddc57", + "size": 37254, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-tinyfd-natives-linux-arm64-3.2.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.2.3/lwjgl-tinyfd-natives-linux-x86-3.2.3.jar", + "sha1": "3c77da543f18a1d0c996aa79752a40e0af49f439", + "size": 41555, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-tinyfd-natives-linux-x86-3.2.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.2.3/lwjgl-tinyfd-natives-linux-3.2.3.jar", + "sha1": "712bd84303bbf62db92591e9ba8f7f30965905f1", + "size": 37100, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-tinyfd-natives-linux-3.2.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-glfw:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-tinyfd:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-stb:3.2.3": { + "name": "org.lwjgl:lwjgl-stb:3.2.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.2.3/lwjgl-stb-3.2.3.jar", + "sha1": "d8927eebe06166596b123250002cc83402713cde", + "size": 104829, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-stb-3.2.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.2.3/lwjgl-stb-natives-linux-arm32-3.2.3.jar", + "sha1": "eff0c1f5bbaad48176a26fda6752d93212b1698d", + "size": 179724, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-stb-natives-linux-arm32-3.2.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.2.3/lwjgl-stb-natives-linux-arm64-3.2.3.jar", + "sha1": "88f542ec81a8d591ccbca74895e834d833ccb85c", + "size": 216177, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-stb-natives-linux-arm64-3.2.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.2.3/lwjgl-stb-natives-linux-x86-3.2.3.jar", + "sha1": "3aa933ac9195ef50edf096815e403cd18f6ae8a8", + "size": 229926, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-stb-natives-linux-x86-3.2.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.2.3/lwjgl-stb-natives-linux-3.2.3.jar", + "sha1": "3a35d2b98d6cbde8dd3b2cf0c8638010792328b0", + "size": 232273, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-stb-natives-linux-3.2.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-glfw:3.2.3": { + "name": "org.lwjgl:lwjgl-glfw:3.2.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.2.3/lwjgl-glfw-3.2.3.jar", + "sha1": "3a957cf525f4f5fd167757c32d66620b0e7a9040", + "size": 109105, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-glfw-3.2.3.jar" + } + } + }, + "org.lwjgl:lwjgl-freetype:3.3.6": { + "name": "org.lwjgl:lwjgl-freetype:3.3.6", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.6/lwjgl-freetype-3.3.6.jar", + "sha1": "b2c35c3d50118f8e3ad66d35d79cb933f58e22aa", + "size": 460945, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-freetype-3.3.6.jar" + } + } + }, + "org.lwjgl:lwjgl-openal:3.3.2": { + "name": "org.lwjgl:lwjgl-openal:3.3.2", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2.jar", + "sha1": "32dd4ea32e20809865f64631af0f2faef0f4420c", + "size": 102407, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-openal-3.3.2.jar" + } + } + }, + "org.lwjgl:lwjgl-openal:3.3.3": { + "name": "org.lwjgl:lwjgl-openal:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar", + "sha1": "26412fac29ec27d4cfd253a5efcdf691512195db", + "size": 104764, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-openal-3.3.3.jar" + } + } + }, + "org.lwjgl:lwjgl-freetype:3.3.3": { + "name": "org.lwjgl:lwjgl-freetype:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar", + "sha1": "4f315722559c4ed165348e02ec6a5692e005b54c", + "size": 451946, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-freetype-3.3.3.jar" + } + } + }, + "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl:3.4.1": { + "name": "org.lwjgl:lwjgl:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar", + "sha1": "87ada832a305c12a8028a26b781f85d24d65d313", + "size": 881150, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-natives-linux-arm32-3.4.1.jar", + "sha1": "440aac2591334ce8f1416455c58f6ceba3180c29", + "size": 88422, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-natives-linux-arm64-3.4.1.jar", + "sha1": "d3e400b9bf2eff95ec498abc7c36590df1c5130c", + "size": 109095, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-natives-linux-x86-3.4.1.jar", + "sha1": "1c381647408b64b49051cbacd9a3c576fa1325a8", + "size": 127452, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-natives-linux-3.4.1.jar", + "sha1": "142d953e70b51192a2313dc2ea1abf1f761f1508", + "size": 111440, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-natives-linux-3.4.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-openal:3.3.6": { + "name": "org.lwjgl:lwjgl-openal:3.3.6", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.6/lwjgl-openal-3.3.6.jar", + "sha1": "1538ba7db93789211af9db08489715b218d1c4a1", + "size": 145306, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-openal-3.3.6.jar" + } + } + }, + "org.lwjgl:lwjgl-freetype:3.3.2": { + "name": "org.lwjgl:lwjgl-freetype:3.3.2", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.2/lwjgl-freetype-3.3.2.jar", + "sha1": "eacb4a7a42968e37f6bd46900485c3c65258b86d", + "size": 441247, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-freetype-3.3.2.jar" + } + } + }, + "org.lwjgl:lwjgl-openal:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-opengl:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-vma:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-patch": { + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-patch", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar", + "sha1": "4f315722559c4ed165348e02ec6a5692e005b54c", + "size": 451946, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-freetype-3.3.3.jar" + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.3": { + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar", + "sha1": "ac7c26e4b096a934236b99be31717b5c0fda806a", + "size": 10033, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-tinyfd-3.3.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-natives-linux-arm32-3.3.3.jar", + "sha1": "e4c1c17dcd433e05b2480ab386f78ed369815e08", + "size": 48958, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-tinyfd-natives-linux-arm32-3.3.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-natives-linux-arm64-3.3.3.jar", + "sha1": "9fa4662061b12798af26c50c2d65631afcfeeed0", + "size": 42096, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-tinyfd-natives-linux-arm64-3.3.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-natives-linux-x86-3.3.3.jar", + "sha1": "6a380744709fa5f1e028eb1b9e7b4f1b30ff8701", + "size": 47872, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-tinyfd-natives-linux-x86-3.3.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-natives-linux-3.3.3.jar", + "sha1": "23ed1098764fa6d7742e5886439b80901907bc84", + "size": 42872, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-tinyfd-natives-linux-3.3.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.2": { + "name": "org.lwjgl:lwjgl-tinyfd:3.3.2", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2.jar", + "sha1": "64e0275b141b38f90a0e0974b7544fbd399adde6", + "size": 10036, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-tinyfd-3.3.2.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-natives-linux-arm32-3.3.2.jar", + "sha1": "e56ac954f1e22904b58db066b53c8ee28619cd32", + "size": 48416, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-tinyfd-natives-linux-arm32-3.3.2.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-natives-linux-arm64-3.3.2.jar", + "sha1": "9b85cc78c9dddce1332abf4a9398782e71cab5a9", + "size": 42478, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-tinyfd-natives-linux-arm64-3.3.2.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-natives-linux-x86-3.3.2.jar", + "sha1": "ef6625678cbd4cd259ad1865274e1a97e680cda1", + "size": 47710, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-tinyfd-natives-linux-x86-3.3.2.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-natives-linux-3.3.2.jar", + "sha1": "33aba1181c43d0a12ac7196d15f44be64aae43f5", + "size": 42333, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-tinyfd-natives-linux-3.3.2.jar" + } + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.1": { + "name": "org.lwjgl:lwjgl-tinyfd:3.3.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1.jar", + "sha1": "9e51cebbfb8674e48ca83df86cf870963df70bde", + "size": 6803, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-tinyfd-3.3.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-natives-linux-arm32-3.3.1.jar", + "sha1": "deff8e4eaae04c61ed8ae04588f14db69abcc4c9", + "size": 47731, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-tinyfd-natives-linux-arm32-3.3.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-natives-linux-arm64-3.3.1.jar", + "sha1": "aef7c26e3d5f64c8e060f6c7de2b9639f03fa718", + "size": 41777, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-tinyfd-natives-linux-arm64-3.3.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-natives-linux-x86-3.3.1.jar", + "sha1": "60f7d489bf9e0ae0e8cade51b282b41d1e5c6278", + "size": 47032, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-tinyfd-natives-linux-x86-3.3.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-natives-linux-3.3.1.jar", + "sha1": "037138b681ad94e2092a66511ecbc168b2322c79", + "size": 41633, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-tinyfd-natives-linux-3.3.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-openal:3.3.1": { + "name": "org.lwjgl:lwjgl-openal:3.3.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1.jar", + "sha1": "04f90471ca37a275b5576fa6c8df3cb3895cfd80", + "size": 88879, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-openal-3.3.1.jar" + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.6": { + "name": "org.lwjgl:lwjgl-tinyfd:3.3.6", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.6/lwjgl-tinyfd-3.3.6.jar", + "sha1": "3bc823730818efb4878d35e67c74a6fcd38786ff", + "size": 10089, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-tinyfd-3.3.6.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.6/lwjgl-tinyfd-natives-linux-arm32-3.3.6.jar", + "sha1": "2e6ffbbc10b9f5df2f616b96f84dd93268a8e014", + "size": 50236, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-tinyfd-natives-linux-arm32-3.3.6.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.6/lwjgl-tinyfd-natives-linux-arm64-3.3.6.jar", + "sha1": "a88b756f64a34f377d848e8e0c34733005a9e7c0", + "size": 43315, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-tinyfd-natives-linux-arm64-3.3.6.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.6/lwjgl-tinyfd-natives-linux-x86-3.3.6.jar", + "sha1": "481c9b5ba82d7d4b9a3fc7bac653ac801dee082f", + "size": 48472, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-tinyfd-natives-linux-x86-3.3.6.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.6/lwjgl-tinyfd-natives-linux-3.3.6.jar", + "sha1": "bea92661bb8da0643996132a117b32d17dbb6967", + "size": 43811, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-tinyfd-natives-linux-3.3.6.jar" + } + } + } + }, + "org.lwjgl:lwjgl-freetype:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.2.3": { + "name": "org.lwjgl:lwjgl-jemalloc:3.2.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.2.3/lwjgl-rpmalloc-3.2.3.jar", + "sha1": "e7ffee118043b3026262bd7aef83b4964caa22b1", + "size": 28527, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-rpmalloc-3.2.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.2.3/lwjgl-rpmalloc-natives-linux-arm32-3.2.3.jar", + "sha1": "9f06fd098e6c3de753d25ba077f1a74f89abf3c7", + "size": 16623, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-rpmalloc-natives-linux-arm32-3.2.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.2.3/lwjgl-rpmalloc-natives-linux-arm64-3.2.3.jar", + "sha1": "adbdb799ea24e2ca2f5ca303bd949c3f251876a3", + "size": 11928, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-rpmalloc-natives-linux-arm64-3.2.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.2.3/lwjgl-rpmalloc-natives-linux-x86-3.2.3.jar", + "sha1": "5383ed4a3cdf67b896ecf344140e65ec72e6ffaa", + "size": 13542, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-rpmalloc-natives-linux-x86-3.2.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.2.3/lwjgl-rpmalloc-natives-linux-3.2.3.jar", + "sha1": "ebe33f2ed743d573a4ca139dd2f64747f9d2bf6b", + "size": 13118, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-rpmalloc-natives-linux-3.2.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-stb:3.3.6": { + "name": "org.lwjgl:lwjgl-stb:3.3.6", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.6/lwjgl-stb-3.3.6.jar", + "sha1": "6dfbc47bde87f162f040ca848eeaa7dc61729937", + "size": 140909, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-stb-3.3.6.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.3.6/lwjgl-stb-natives-linux-arm32-3.3.6.jar", + "sha1": "32c0e4c2c36aef5169767bb7ddadd0bec2ed66af", + "size": 244034, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-stb-natives-linux-arm32-3.3.6.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.3.6/lwjgl-stb-natives-linux-arm64-3.3.6.jar", + "sha1": "f2a4ddbaf2d99669c26b86d71029a2d67a588be0", + "size": 256771, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-stb-natives-linux-arm64-3.3.6.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.3.6/lwjgl-stb-natives-linux-x86-3.3.6.jar", + "sha1": "1f317f29d38b8056f93d4f91e4e4454a60314738", + "size": 294699, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-stb-natives-linux-x86-3.3.6.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.3.6/lwjgl-stb-natives-linux-3.3.6.jar", + "sha1": "707e101aefe9dae948fcfe91e851c7c63ac1ae27", + "size": 308122, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-stb-natives-linux-3.3.6.jar" + } + } + } + }, + "org.lwjgl:lwjgl-stb:3.3.1": { + "name": "org.lwjgl:lwjgl-stb:3.3.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1.jar", + "sha1": "26b3c570cd508b41b3e42d34f90699c26d38b502", + "size": 113270, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-stb-3.3.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-natives-linux-arm32-3.3.1.jar", + "sha1": "4946e019f17844f9421eca174fe78aafacd74e29", + "size": 195401, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-stb-natives-linux-arm32-3.3.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-natives-linux-arm64-3.3.1.jar", + "sha1": "7d57e162d04dfa387c0da165b6aee777fd278a73", + "size": 228113, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-stb-natives-linux-arm64-3.3.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-natives-linux-x86-3.3.1.jar", + "sha1": "bfa9d261ace2307ad5857668b24c41c0e58c07ef", + "size": 241677, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-stb-natives-linux-x86-3.3.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-natives-linux-3.3.1.jar", + "sha1": "9aebaacf050c5a6f74720a5c9291d24374d00284", + "size": 258628, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-stb-natives-linux-3.3.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-stb:3.3.2": { + "name": "org.lwjgl:lwjgl-stb:3.3.2", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2.jar", + "sha1": "69ddcc8e6413208944b496c104242eeeeb6a3ad8", + "size": 116431, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-stb-3.3.2.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-natives-linux-arm32-3.3.2.jar", + "sha1": "cd9a743e357d0c9b87e772b6cfb86ca94af77a07", + "size": 197408, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-stb-natives-linux-arm32-3.3.2.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-natives-linux-arm64-3.3.2.jar", + "sha1": "aaab79c0429c041af68629363c888a458d5efd49", + "size": 228787, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-stb-natives-linux-arm64-3.3.2.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-natives-linux-x86-3.3.2.jar", + "sha1": "2630b3df7075c9f88d4181a8a53b6c3fed5d5171", + "size": 243212, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-stb-natives-linux-x86-3.3.2.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-natives-linux-3.3.2.jar", + "sha1": "76237d19e74abdf5a035d453302632aee1dad767", + "size": 259667, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-stb-natives-linux-3.3.2.jar" + } + } + } + }, + "org.lwjgl:lwjgl-openal:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-stb:3.3.3": { + "name": "org.lwjgl:lwjgl-stb:3.3.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar", + "sha1": "bea4158bc7b6e2eb416b33d08a8048e13054c1c1", + "size": 118222, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-stb-3.3.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-natives-linux-arm32-3.3.3.jar", + "sha1": "ace26fbd75c5debbb30fdedd1a239931aa0ba97d", + "size": 197406, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-stb-natives-linux-arm32-3.3.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-natives-linux-arm64-3.3.3.jar", + "sha1": "30542c0530ed334667cc44dd833f4054a6ede311", + "size": 228786, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-stb-natives-linux-arm64-3.3.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-natives-linux-x86-3.3.3.jar", + "sha1": "fe6941d46eb1bac831605f497802014246b4223d", + "size": 243210, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-stb-natives-linux-x86-3.3.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-natives-linux-3.3.3.jar", + "sha1": "0970bb1c447a337f22bde19c805a7f06654b06e5", + "size": 259664, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-stb-natives-linux-3.3.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-spvc:3.4.1": { + "name": "org.lwjgl:lwjgl-spvc:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-spvc/3.4.1/lwjgl-spvc-3.4.1.jar", + "sha1": "52ef2dd846cc46ee332cb69cb99aaa52200f3280", + "size": 141549, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-spvc-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl-freetype:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-openal:3.4.1": { + "name": "org.lwjgl:lwjgl-openal:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.4.1/lwjgl-openal-3.4.1.jar", + "sha1": "74aa57594c5083cc0fd1884b6edcf764cf5ff211", + "size": 146193, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-openal-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl-stb:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-tinyfd:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-stb:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-glfw:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-vma:3.4.1": { + "name": "org.lwjgl:lwjgl-vma:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-3.4.1.jar", + "sha1": "b8edf17d4516f9c614555b7f06a040c848b21a5c", + "size": 105632, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vma-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-natives-linux-arm32-3.4.1.jar", + "sha1": "8a704995a034663ff110dfae7fb48189856a1cb8", + "size": 242948, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vma-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-natives-linux-arm64-3.4.1.jar", + "sha1": "077b0e9a2dac0e1c5e11ec224ebb11bb5cee73cc", + "size": 279208, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vma-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-natives-linux-x86-3.4.1.jar", + "sha1": "1e3718a6102197aaa8695fa032b6dbd165ff4a4a", + "size": 319374, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vma-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-natives-linux-3.4.1.jar", + "sha1": "a2a268ebbb1d08874302fbd2bd2fb5b30efa2bf8", + "size": 294344, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-vma-natives-linux-3.4.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-glfw:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.3.6": { + "name": "org.lwjgl:lwjgl-jemalloc:3.3.6", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.6/lwjgl-rpmalloc-3.3.6.jar", + "sha1": "c3f9775f6f184d6f04974edef6d9574b4cdf0cff", + "size": 40681, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-rpmalloc-3.3.6.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.6/lwjgl-rpmalloc-natives-linux-arm32-3.3.6.jar", + "sha1": "1beaa969f8458d5ecd8fdceb62306d0fb8c8c3ce", + "size": 20193, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-rpmalloc-natives-linux-arm32-3.3.6.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.6/lwjgl-rpmalloc-natives-linux-arm64-3.3.6.jar", + "sha1": "445f2409e702d2b670d5eba796a80f89e8fe0c58", + "size": 18626, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-rpmalloc-natives-linux-arm64-3.3.6.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.6/lwjgl-rpmalloc-natives-linux-x86-3.3.6.jar", + "sha1": "ba752fa1bb6db159ff7e63ddd1395ea0a59ed2c1", + "size": 18872, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-rpmalloc-natives-linux-x86-3.3.6.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.6/lwjgl-rpmalloc-natives-linux-3.3.6.jar", + "sha1": "b59c94583828d6d3e75183282cb86de5527e79ff", + "size": 18168, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-rpmalloc-natives-linux-3.3.6.jar" + } + } + } + }, + "org.lwjgl:lwjgl:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-opengl:3.4.1": { + "name": "org.lwjgl:lwjgl-opengl:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-3.4.1.jar", + "sha1": "cedd7eb3b328d7f80cb041774949c60e984446dc", + "size": 939818, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-opengl-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-natives-linux-arm32-3.4.1.jar", + "sha1": "06606c8fa470620f4c6eae074d9dc2b144f6b251", + "size": 49008, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-opengl-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-natives-linux-arm64-3.4.1.jar", + "sha1": "0ee06b3c8a98cea6785e04bf43884b152d8614c8", + "size": 71770, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-opengl-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-natives-linux-x86-3.4.1.jar", + "sha1": "41bba9d30ab5362943cc9729f2d6e93a3f9272d3", + "size": 75873, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-opengl-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-natives-linux-3.4.1.jar", + "sha1": "4618be9fb8b9fa2a1e8fd0309ff757e24cf9ca2f", + "size": 69350, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-opengl-natives-linux-3.4.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.4.1": { + "name": "org.lwjgl:lwjgl-tinyfd:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-3.4.1.jar", + "sha1": "ad47b01b5952e7c524fc4421479814b88b962c5b", + "size": 9997, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-tinyfd-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-natives-linux-arm32-3.4.1.jar", + "sha1": "943835fe01d6ddf7c0c49b5f39ea4bd8280ddf5b", + "size": 51080, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-tinyfd-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-natives-linux-arm64-3.4.1.jar", + "sha1": "553afea7d5f4693a3fb20801779d06bf9b8912ce", + "size": 43429, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-tinyfd-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-natives-linux-x86-3.4.1.jar", + "sha1": "ad165bbd22a46c7435c35ed360507e9130e6c3b1", + "size": 49112, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-tinyfd-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-natives-linux-3.4.1.jar", + "sha1": "ec70a058c64fc5297795ca4819c1eb1b8d178e92", + "size": 44268, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-tinyfd-natives-linux-3.4.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-spvc:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.3.2": { + "name": "org.lwjgl:lwjgl-jemalloc:3.3.2", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.2/lwjgl-rpmalloc-3.3.2.jar", + "sha1": "e4bea8dedf7f7bc693bc234024bb85cf4dddfbe4", + "size": 39251, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-rpmalloc-3.3.2.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.2/lwjgl-rpmalloc-natives-linux-arm32-3.3.2.jar", + "sha1": "07cd7882ca438c2be0d4c2f0a6bfc924ad7207c0", + "size": 20132, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-rpmalloc-natives-linux-arm32-3.3.2.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.2/lwjgl-rpmalloc-natives-linux-arm64-3.3.2.jar", + "sha1": "c8a2b05e04fd84fb258ce8a05e5dc443c7b2e3b0", + "size": 18527, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-rpmalloc-natives-linux-arm64-3.3.2.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.2/lwjgl-rpmalloc-natives-linux-x86-3.3.2.jar", + "sha1": "fcb61f36255ab43777473f477c3f18c8b3765569", + "size": 18761, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-rpmalloc-natives-linux-x86-3.3.2.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.2/lwjgl-rpmalloc-natives-linux-3.3.2.jar", + "sha1": "b899222a368d09aa7d91f661592dc21b8455dfb1", + "size": 17977, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-rpmalloc-natives-linux-3.3.2.jar" + } + } + } + }, + "org.lwjgl:lwjgl-jemalloc:3.3.3": { + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.3/lwjgl-rpmalloc-3.3.3.jar", + "sha1": "c03db1c92ff9232111fe916717289b9950fa07db", + "size": 40037, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-rpmalloc-3.3.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.3/lwjgl-rpmalloc-natives-linux-arm32-3.3.3.jar", + "sha1": "47444488fe749162d7f10ef0556db7a95e388800", + "size": 20163, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-rpmalloc-natives-linux-arm32-3.3.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.3/lwjgl-rpmalloc-natives-linux-arm64-3.3.3.jar", + "sha1": "a636853c2529652f906e0c2126830381ca936f23", + "size": 18578, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-rpmalloc-natives-linux-arm64-3.3.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.3/lwjgl-rpmalloc-natives-linux-x86-3.3.3.jar", + "sha1": "85c2b68b7d9b978520c1b6c4b5cd3116e0a6aceb", + "size": 18839, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-rpmalloc-natives-linux-x86-3.3.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.3/lwjgl-rpmalloc-natives-linux-3.3.3.jar", + "sha1": "b196323425a4c9a311a0e02e61db52df7be9cced", + "size": 18037, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-rpmalloc-natives-linux-3.3.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-jemalloc:3.3.1": { + "name": "org.lwjgl:lwjgl-jemalloc:3.3.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.1/lwjgl-rpmalloc-3.3.1.jar", + "sha1": "77b7d93a1038b66243a2336821e23f0686a0fcd9", + "size": 35491, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-rpmalloc-3.3.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.1/lwjgl-rpmalloc-natives-linux-arm32-3.3.1.jar", + "sha1": "eeee4a8b7f324f64e0e879e3bc7694d6d4cc9067", + "size": 19347, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-rpmalloc-natives-linux-arm32-3.3.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.1/lwjgl-rpmalloc-natives-linux-arm64-3.3.1.jar", + "sha1": "ef3539440ebb3ae97529926e2dd993bb99065d7f", + "size": 17900, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-rpmalloc-natives-linux-arm64-3.3.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.1/lwjgl-rpmalloc-natives-linux-x86-3.3.1.jar", + "sha1": "9c6919864d4dd0329a7aaba25c8a20edd7e6ef57", + "size": 18082, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-rpmalloc-natives-linux-x86-3.3.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.3.1/lwjgl-rpmalloc-natives-linux-3.3.1.jar", + "sha1": "2c3ed7cbf53679fcd4477f0524e704b61daa975f", + "size": 17275, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-rpmalloc-natives-linux-3.3.1.jar" + } + } + } + }, + "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw": { + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-glfw/lwjgl-platform-2.9.4-glfw-natives-armeabi-v7a.jar", + "sha1": "6539f21c2796ba11f81a3b05fd064c220299b39c", + "size": 76863, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl-native-armeabi-v7a.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-glfw/lwjgl-platform-2.9.4-glfw-natives-arm64-v8a.jar", + "sha1": "15f0a7567eee030c452fc5d98ec5527019afa206", + "size": 99240, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl-native-arm64-v8a.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-glfw/lwjgl-platform-2.9.4-glfw-natives-x86.jar", + "sha1": "06e390192230fe081ed1cf2da1134d468dcb6688", + "size": 107351, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl-native-x86.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-glfw/lwjgl-platform-2.9.4-glfw-natives-x86_64.jar", + "sha1": "cb27261859f162874bb685d6f216e0bd9622d917", + "size": 100331, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl-native-x86_64.jar" + } + } + }, + "extract": {} + }, + "org.lwjgl:lwjgl-stb:3.4.1": { + "name": "org.lwjgl:lwjgl-stb:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-3.4.1.jar", + "sha1": "d9988c39c71a78764d2ddee86a5a9d04967efe60", + "size": 139219, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-stb-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-natives-linux-arm32-3.4.1.jar", + "sha1": "c2906967aa5eb829a97d2d2463406f517d82a9b8", + "size": 244357, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-stb-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-natives-linux-arm64-3.4.1.jar", + "sha1": "0b864af5ab9464f20194a70c9e8b94724be353f1", + "size": 257061, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-stb-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-natives-linux-x86-3.4.1.jar", + "sha1": "5248fea1b871f1fda602f47ac6ce9e76568de602", + "size": 294762, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-stb-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-natives-linux-3.4.1.jar", + "sha1": "2a47fd12505bdfb9c0bc4d0bce83dd051087e82b", + "size": 307523, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-stb-natives-linux-3.4.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-shaderc:3.4.1": { + "name": "org.lwjgl:lwjgl-shaderc:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-shaderc/3.4.1/lwjgl-shaderc-3.4.1.jar", + "sha1": "dbd627f1abd33e70776ee3aac380d832561261c1", + "size": 146713, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-shaderc-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl-stb:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-openal:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-glfw:3.4.1": { + "name": "org.lwjgl:lwjgl-glfw:3.4.1", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.4.1/lwjgl-glfw-3.4.1.jar", + "sha1": "b2182bcf492e66bbf2afe4c10ea1144b0afbeacf", + "size": 144554, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-glfw-3.4.1.jar" + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl:3.2.3": { + "name": "org.lwjgl:lwjgl:3.2.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.2.3/lwjgl-3.2.3.jar", + "sha1": "8dbcbf1397794074e237145f85ea470b24d2b53e", + "size": 557745, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-3.2.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl/3.2.3/lwjgl-natives-linux-arm32-3.2.3.jar", + "sha1": "35125b1154c995aa584aafb971a6b8972daa4b5d", + "size": 47672, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-natives-linux-arm32-3.2.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl/3.2.3/lwjgl-natives-linux-arm64-3.2.3.jar", + "sha1": "d6427a875c2cd3885a7799d45d03e048c7ce11f3", + "size": 65263, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-natives-linux-arm64-3.2.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl/3.2.3/lwjgl-natives-linux-x86-3.2.3.jar", + "sha1": "b072547fbf81679c17b620c0f2242af2c79435b3", + "size": 78643, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-natives-linux-x86-3.2.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl/3.2.3/lwjgl-natives-linux-3.2.3.jar", + "sha1": "2afdb752fa799607e8221aef3c26e9cb7a091235", + "size": 66125, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.2.3-r3/lwjgl-natives-linux-3.2.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.4.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-opengl:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw": { + "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl_util/2.9.4-glfw/lwjgl_util-2.9.4-glfw.jar", + "sha1": "524f1c62bc747c0d29a304f6659f4591906867b4", + "size": 180578, + "url": "https://github.com/MojoLauncher/lwjgl2-glfw/releases/download/v6m/lwjgl_util.jar" + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.1": { + "name": "org.lwjgl:lwjgl-opengl:3.3.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1.jar", + "sha1": "08ea19ee5ef669463c40aad2b19e5e934d606e49", + "size": 929218, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-opengl-3.3.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-natives-linux-arm32-3.3.1.jar", + "sha1": "c91bd61a5e438ad5ee1435150bc29b371a7692c7", + "size": 47884, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-opengl-natives-linux-arm32-3.3.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-natives-linux-arm64-3.3.1.jar", + "sha1": "aed3d5ee64f3acebd7ada796f510483affa174d0", + "size": 70656, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-opengl-natives-linux-arm64-3.3.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-natives-linux-x86-3.3.1.jar", + "sha1": "7334356813953fc794116034ef9445cc4b89caa6", + "size": 74759, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-opengl-natives-linux-x86-3.3.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-natives-linux-3.3.1.jar", + "sha1": "5ebbebca52dab40a386cdcff98fd7e99e8e225a1", + "size": 68345, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.1-r3/lwjgl-opengl-natives-linux-3.3.1.jar" + } + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.3": { + "name": "org.lwjgl:lwjgl-opengl:3.3.3", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar", + "sha1": "29a0debf7a67e12c65c02256a4048da72f32daea", + "size": 933522, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-opengl-3.3.3.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-natives-linux-arm32-3.3.3.jar", + "sha1": "46c9ea1d572a03cb3efc830ffd973ab4066b8282", + "size": 48784, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-opengl-natives-linux-arm32-3.3.3.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-natives-linux-arm64-3.3.3.jar", + "sha1": "c48f2d8c7ea23708c22fde454cacf6994da4fdc7", + "size": 71449, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-opengl-natives-linux-arm64-3.3.3.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-natives-linux-x86-3.3.3.jar", + "sha1": "97db06e9071c1e0ce06a71246a99119d40504c41", + "size": 75495, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-opengl-natives-linux-x86-3.3.3.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-natives-linux-3.3.3.jar", + "sha1": "c3a8c22ee7a7d3c9dafad490685ea988a18dac65", + "size": 69070, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.3-r3/lwjgl-opengl-natives-linux-3.3.3.jar" + } + } + } + }, + "org.lwjgl:lwjgl-opengl:3.3.2": { + "name": "org.lwjgl:lwjgl-opengl:3.3.2", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2.jar", + "sha1": "cb4f4bb1b03b4fe51637466569ee6e4a8d58a9bf", + "size": 933169, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-opengl-3.3.2.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-natives-linux-arm32-3.3.2.jar", + "sha1": "6646a92230c54adcff309d0dd369dbb0c07d7968", + "size": 48785, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-opengl-natives-linux-arm32-3.3.2.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-natives-linux-arm64-3.3.2.jar", + "sha1": "d6791173e021f5cda9b60dce5d5bad9ef06690d6", + "size": 71450, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-opengl-natives-linux-arm64-3.3.2.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-natives-linux-x86-3.3.2.jar", + "sha1": "89fa870e7987293847c23a45ea17a0051a1344cb", + "size": 75498, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-opengl-natives-linux-x86-3.3.2.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-natives-linux-3.3.2.jar", + "sha1": "f4a1a99b02ec229c23eff98cc1d9d66fd52d0f83", + "size": 69072, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.2-r3/lwjgl-opengl-natives-linux-3.3.2.jar" + } + } + } + }, + "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-linux": { + "skip": true + }, + "net.java.jinput:jinput-platform:2.0.5": { + "skip": true + }, + "org.lwjgl:lwjgl-openal:3.3.2:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-opengl:3.3.6": { + "name": "org.lwjgl:lwjgl-opengl:3.3.6", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.6/lwjgl-opengl-3.3.6.jar", + "sha1": "ef5101f6681f32f1609a6a24c45698b4d8cd811a", + "size": 937204, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-opengl-3.3.6.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-opengl/3.3.6/lwjgl-opengl-natives-linux-arm32-3.3.6.jar", + "sha1": "4617e134e48af5090e54262cfce3cfb00f44f129", + "size": 48791, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-opengl-natives-linux-arm32-3.3.6.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-opengl/3.3.6/lwjgl-opengl-natives-linux-arm64-3.3.6.jar", + "sha1": "1e2ba7f5615505326148228f6fea140de0452184", + "size": 71455, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-opengl-natives-linux-arm64-3.3.6.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-opengl/3.3.6/lwjgl-opengl-natives-linux-x86-3.3.6.jar", + "sha1": "b5d2be0409592bea2a1ca7eaa587c89d336b1cea", + "size": 75503, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-opengl-natives-linux-x86-3.3.6.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-opengl/3.3.6/lwjgl-opengl-natives-linux-3.3.6.jar", + "sha1": "2aece7fb552fb93691a72a434d4cf5aaa1fb7577", + "size": 69078, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.3.6-r3/lwjgl-opengl-natives-linux-3.3.6.jar" + } + } + } + }, + "org.lwjgl:lwjgl:3.3.1:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.3.6:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-freetype:3.3.3:natives-linux": { + "skip": true + }, + "org.lwjgl:lwjgl-jemalloc:3.4.1": { + "name": "org.lwjgl:lwjgl-jemalloc:3.4.1", + "natives": { + "android-arm": "natives-linux-arm32", + "android-x86": "natives-linux-x86", + "android-x86_64": "natives-linux", + "android-arm64": "natives-linux-arm64" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.4.1/lwjgl-rpmalloc-3.4.1.jar", + "sha1": "49a38370d0e61f83c86bf0f949cc04e8393c9d54", + "size": 40767, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-rpmalloc-3.4.1.jar" + }, + "classifiers": { + "natives-linux-arm32": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.4.1/lwjgl-rpmalloc-natives-linux-arm32-3.4.1.jar", + "sha1": "2fcdf1561a2da780a54585e4c1f6bfe397fc2445", + "size": 20193, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-rpmalloc-natives-linux-arm32-3.4.1.jar" + }, + "natives-linux-arm64": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.4.1/lwjgl-rpmalloc-natives-linux-arm64-3.4.1.jar", + "sha1": "64d3951b73b5c33732d99b7a7f4518402a4b56b9", + "size": 18627, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-rpmalloc-natives-linux-arm64-3.4.1.jar" + }, + "natives-linux-x86": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.4.1/lwjgl-rpmalloc-natives-linux-x86-3.4.1.jar", + "sha1": "43e17622a6b5166b3beac6f6381c998cc43cf1e3", + "size": 18872, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-rpmalloc-natives-linux-x86-3.4.1.jar" + }, + "natives-linux": { + "path": "org/lwjgl/lwjgl-rpmalloc/3.4.1/lwjgl-rpmalloc-natives-linux-3.4.1.jar", + "sha1": "02e2447aa8370ff39f3ed1e8397601739faf2316", + "size": 18168, + "url": "https://github.com/MojoLauncher/unilwjgl3-builder/releases/download/v3.4.1-r3/lwjgl-rpmalloc-natives-linux-3.4.1.jar" + } + } + } + } + }, + "artifactMapping": { + "org.lwjgl:lwjgl:3.1.6": "org.lwjgl:lwjgl:3.2.3", + "org.lwjgl.lwjgl:lwjgl:2.9.1-nightly-20131120": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "org.lwjgl:lwjgl-glfw:3.2.2": "org.lwjgl:lwjgl-glfw:3.2.3", + "org.lwjgl:lwjgl-glfw:3.1.6": "org.lwjgl:lwjgl-glfw:3.2.3", + "org.lwjgl:lwjgl-openal:3.1.6": "org.lwjgl:lwjgl-openal:3.2.3", + "org.lwjgl.lwjgl:lwjgl_util:2.9.0": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl_util:2.9.1": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl_util:2.9.3": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "org.lwjgl:lwjgl:3.2.1": "org.lwjgl:lwjgl:3.2.3", + "org.lwjgl:lwjgl:3.1.2": "org.lwjgl:lwjgl:3.2.3", + "org.lwjgl:lwjgl:3.2.2": "org.lwjgl:lwjgl:3.2.3", + "org.lwjgl.lwjgl:lwjgl:2.9.3": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl:2.9.1": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "org.lwjgl:lwjgl-jemalloc:3.1.6": "org.lwjgl:lwjgl-jemalloc:3.2.3", + "org.lwjgl:lwjgl-opengl:3.2.2": "org.lwjgl:lwjgl-opengl:3.2.3", + "org.lwjgl.lwjgl:lwjgl:2.9.0": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "org.lwjgl:lwjgl-opengl:3.2.1": "org.lwjgl:lwjgl-opengl:3.2.3", + "org.lwjgl:lwjgl-opengl:3.1.2": "org.lwjgl:lwjgl-opengl:3.2.3", + "org.lwjgl:lwjgl-opengl:3.1.6": "org.lwjgl:lwjgl-opengl:3.2.3", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.3": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.1": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "org.lwjgl:lwjgl-tinyfd:3.2.2": "org.lwjgl:lwjgl-tinyfd:3.2.3", + "org.lwjgl:lwjgl-openal:3.2.1": "org.lwjgl:lwjgl-openal:3.2.3", + "org.lwjgl:lwjgl-openal:3.1.2": "org.lwjgl:lwjgl-openal:3.2.3", + "org.lwjgl:lwjgl-openal:3.2.2": "org.lwjgl:lwjgl-openal:3.2.3", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.0": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "org.lwjgl:lwjgl-stb:3.1.6": "org.lwjgl:lwjgl-stb:3.2.3", + "org.lwjgl:lwjgl-jemalloc:3.2.1": "org.lwjgl:lwjgl-jemalloc:3.2.3", + "org.lwjgl:lwjgl-jemalloc:3.1.2": "org.lwjgl:lwjgl-jemalloc:3.2.3", + "org.lwjgl.lwjgl:lwjgl_util:2.9.1-nightly-20131120": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-glfw", + "org.lwjgl:lwjgl-jemalloc:3.2.2": "org.lwjgl:lwjgl-jemalloc:3.2.3", + "org.lwjgl:lwjgl-glfw:3.2.1": "org.lwjgl:lwjgl-glfw:3.2.3", + "org.lwjgl:lwjgl-glfw:3.1.2": "org.lwjgl:lwjgl-glfw:3.2.3", + "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209": "org.lwjgl.lwjgl:lwjgl:2.9.4-glfw", + "org.lwjgl:lwjgl-stb:3.2.1": "org.lwjgl:lwjgl-stb:3.2.3", + "org.lwjgl:lwjgl-stb:3.1.2": "org.lwjgl:lwjgl-stb:3.2.3", + "org.lwjgl:lwjgl-stb:3.2.2": "org.lwjgl:lwjgl-stb:3.2.3", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.1-nightly-20131120": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-glfw", + "org.lwjgl:lwjgl:3.4.1:unsafe": "org.lwjgl:lwjgl:3.4.1" + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/ic_launcher-playstore.png b/app_pojavlauncher/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..f6f5921ad4 Binary files /dev/null and b/app_pojavlauncher/src/main/ic_launcher-playstore.png differ diff --git a/app_pojavlauncher/src/main/java/com/kdt/LoggerView.java b/app_pojavlauncher/src/main/java/com/kdt/LoggerView.java index 8409fa2686..42538a7eaf 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/LoggerView.java +++ b/app_pojavlauncher/src/main/java/com/kdt/LoggerView.java @@ -60,11 +60,10 @@ private void init(){ (compoundButton, isChecked) -> { mLogTextView.setVisibility(isChecked ? VISIBLE : GONE); if(isChecked) { - Logger.setLogListener(mLogListener); + Logger.addLogListener(mLogListener); }else{ mLogTextView.setText(""); - Logger.setLogListener(null); // Makes the JNI code be able to skip expensive logger callbacks - // NOTE: was tested by rapidly smashing the log on/off button, no sync issues found :) + Logger.removeLogListener(mLogListener); } }); mLogToggle.setChecked(false); diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java new file mode 100644 index 0000000000..97924794f7 --- /dev/null +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java @@ -0,0 +1,365 @@ +package com.kdt.mcgui; + +import android.animation.ValueAnimator; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.AppCompatSpinner; +import androidx.core.content.res.ResourcesCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.authenticator.BackgroundLogin; +import net.kdt.pojavlaunch.authenticator.accounts.Accounts; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; +import net.kdt.pojavlaunch.authenticator.impl.PresentedException; +import net.kdt.pojavlaunch.authenticator.listener.LoginListener; +import net.kdt.pojavlaunch.extra.ExtraConstants; +import net.kdt.pojavlaunch.extra.ExtraCore; +import net.kdt.pojavlaunch.extra.ExtraListener; +import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Objects; + +import fr.spse.extended_view.ExtendedTextView; +import git.artdeell.mojo.R; + +public class AccountSpinner extends AppCompatSpinner implements LoginListener, AdapterView.OnItemSelectedListener, ValueAnimator.AnimatorUpdateListener { + private Adapter mAdapter; + /* Login progress bar stuff */ + private int mMaxSteps = 5; + private final ValueAnimator mLoginStepAnimator = ValueAnimator.ofFloat(mMaxSteps); + private final Paint mLoginBarPaint = new Paint(); + private float mLoginStep; + + class LoginExtraListener implements ExtraListener { + private final AuthType mAuthType; + + LoginExtraListener(AuthType mAuthType) { + this.mAuthType = mAuthType; + } + + @Override + public boolean onValueSet(String key, @NonNull String value) { + mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color)); + BackgroundLogin backgroundLogin = mAuthType.createAuth(); + backgroundLogin.createAccount(AccountSpinner.this, value); + return false; + } + } + + /* Login listeners */ + private final ExtraListener mMicrosoftLoginListener = new LoginExtraListener(AuthType.MICROSOFT); + private final ExtraListener mElyByLoginListener = new LoginExtraListener(AuthType.ELY_BY); + private final ExtraListener mMojangLoginListener = (key, value) -> { + try { + MinecraftAccount minecraftAccount = Accounts.create(acc-> acc.username = value[0]); + onLoginDone(minecraftAccount); + }catch (IOException e) { + onLoginError(e); + } + return false; + }; + + /* Account main menu refresh listener */ + private final ExtraListener mRefreshAccountsListener = (k,v)->{ + reload(); + return false; + }; + + public AccountSpinner(@NonNull Context context, int mode) { + super(context, mode); + init(); + } + + public AccountSpinner(@NonNull Context context) { + super(context); + init(); + } + + public AccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public AccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public AccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mode) { + super(context, attrs, defStyleAttr, mode); + init(); + } + + public AccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mode, Resources.Theme popupTheme) { + super(context, attrs, defStyleAttr, mode, popupTheme); + init(); + } + + + + private void init() { + mAdapter = new Adapter(getContext()); + setAdapter(mAdapter); + setOnItemSelectedListener(this); + reload(); + + setBackgroundColor(getResources().getColor(R.color.background_status_bar)); + mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color)); + mLoginBarPaint.setStrokeWidth(getResources().getDimensionPixelOffset(R.dimen._2sdp)); + mLoginStepAnimator.addUpdateListener(this); + mLoginStep = mMaxSteps; + + ExtraCore.addExtraListener(ExtraConstants.MOJANG_LOGIN_TODO, mMojangLoginListener); + ExtraCore.addExtraListener(ExtraConstants.MICROSOFT_LOGIN_TODO, mMicrosoftLoginListener); + ExtraCore.addExtraListener(ExtraConstants.ELYBY_LOGIN_TODO, mElyByLoginListener); + ExtraCore.addExtraListener(ExtraConstants.REFRESH_ACCOUNT_SPINNER, mRefreshAccountsListener); + } + + private void reload() { + PojavApplication.sExecutorService.execute(()->{ + try { + Accounts accounts = Accounts.load(); + Tools.runOnUiThread(()->refresh(accounts)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + private void refresh(Accounts accounts) { + mAdapter.setNotifyOnChange(false); + mAdapter.clear(); + mAdapter.add(null); + mAdapter.setNotifyOnChange(true); + mAdapter.addAll(accounts.accounts); + + if(accounts.accounts.isEmpty()) { + setSelection(0); + } else { + setSelection(accounts.selectionIndex + 1); + refreshAccount(Objects.requireNonNull((MinecraftAccount) getSelectedItem())); + } + } + + private void refreshAccount(MinecraftAccount minecraftAccount) { + // Wait until all tasks (including other possible login tasks) are done before + // attempting to refresh the account. + ProgressKeeper.waitUntilDone(()->{ + // Reload the account data before attempting to refresh (what if it was already refreshed in the background?) + MinecraftAccount refreshAccount = minecraftAccount.reload(); + if(refreshAccount == null) return; + AuthType authType = refreshAccount.authType; + if(authType.requiresLogin() && System.currentTimeMillis() > refreshAccount.expiresAt) { + authType.createAuth().refreshAccount(this, refreshAccount); + } + }); + } + + private void dismissPopup() { + onDetachedFromWindow(); + onAttachedToWindow(); + } + + private void createAccount() { + setSelection(0); + dismissPopup(); + ExtraCore.setValue(ExtraConstants.SELECT_AUTH_METHOD, true); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + float bottom = getHeight() - mLoginBarPaint.getStrokeWidth()/2; + float lineFillPercent = (mLoginStep / mMaxSteps); + canvas.drawLine(0, bottom, lineFillPercent * getWidth(), bottom, mLoginBarPaint); + } + + @Override + public void onLoginDone(MinecraftAccount account) { + mLoginStep = mMaxSteps; + invalidate(); + + Toast.makeText(getContext(), R.string.main_login_done, Toast.LENGTH_SHORT).show(); + Accounts.setCurrent(account); + reload(); + } + + + @Override + public void onLoginError(Throwable errorMessage) { + mLoginBarPaint.setColor(Color.RED); + invalidate(); + + Context context = getContext(); + if(!(context instanceof Activity)) return; + if(context instanceof LifecycleOwner) { + LifecycleOwner lifecycleOwner = (LifecycleOwner) context; + Lifecycle.State state = lifecycleOwner.getLifecycle().getCurrentState(); + if(state != Lifecycle.State.RESUMED) return; + } + + if(errorMessage instanceof PresentedException) { + PresentedException exception = (PresentedException) errorMessage; + Throwable cause = exception.getCause(); + if(cause == null) { + Tools.dialog(context, context.getString(R.string.global_error), exception.toString(context)); + }else { + Tools.showError(context, exception.toString(context), exception.getCause()); + } + }else { + Tools.showError(getContext(), errorMessage); + } + } + + @Override + public void onLoginProgress(int step) { + mLoginStepAnimator.cancel(); + mLoginStepAnimator.setFloatValues(mLoginStep, step); + mLoginStepAnimator.start(); + } + + @Override + public void setMaxLoginProgress(int max) { + mMaxSteps = max; + } + + @Override + public void onItemSelected(AdapterView adapterView, View view, int i, long l) { + MinecraftAccount minecraftAccount = mAdapter.getItem(i); + if(minecraftAccount == null) { + if(i == 0) { + createAccount(); + }else { + Tools.showError(adapterView.getContext(), new NullPointerException()); + } + return; + } + Accounts.setCurrent(minecraftAccount); + refreshAccount(minecraftAccount); + dismissPopup(); + } + + @Override + public void onNothingSelected(AdapterView adapterView) {} + + @Override + public void onAnimationUpdate(@NonNull ValueAnimator valueAnimator) { + mLoginStep = (float)valueAnimator.getAnimatedValue(); + invalidate(); + } + + private class Adapter extends ArrayAdapter { + private final HashMap mSkinHeadCache = new HashMap<>(); + private final LayoutInflater mInflater; + + + public Adapter(@NonNull Context context) { + super(context, R.layout.item_minecraft_account); + mInflater = LayoutInflater.from(context); + } + + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + if(convertView == null) { + convertView = mInflater.inflate(R.layout.item_minecraft_account, parent, false); + } + populateView(convertView, position, false); + return convertView; + } + + @Override + public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + if(convertView == null) { + convertView = mInflater.inflate(R.layout.item_minecraft_account, parent, false); + } + populateView(convertView, position, true); + return convertView; + } + + private void populateView(View view, int position, boolean isDropDown) { + Resources resources = getResources(); + Resources.Theme theme = getContext().getTheme(); + + ExtendedTextView textview = view.findViewById(R.id.account_item); + ImageView deleteButton = view.findViewById(R.id.delete_account_button); + + if(position == 0) { + // "Add account" button + Drawable plusDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_add, theme); + textview.setCompoundDrawables(plusDrawable, null, null, null); + textview.setText(R.string.main_add_account); + deleteButton.setVisibility(View.GONE); + // Only activate the listener behaviour when in drop-down mode + // or when there's no accounts + if(isDropDown || getCount() == 1) view.setOnClickListener(v-> createAccount()); + return; + } + + if(isDropDown) { + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setOnClickListener((v)->showDeleteDialog(v.getContext(), position)); + }else { + deleteButton.setVisibility(View.GONE); + } + + + MinecraftAccount account = Objects.requireNonNull(getItem(position)); + + int authTypeResource = account.authType.iconResource; + + Drawable authType = null; + if(authTypeResource != 0) { + authType = ResourcesCompat.getDrawable(resources, authTypeResource, theme); + } + + int headCacheHash = System.identityHashCode(account); + BitmapDrawable accountHead = mSkinHeadCache.get(headCacheHash); + if (accountHead == null){ + Bitmap accountSkinFace = account.getSkinFace(); + accountHead = new BitmapDrawable(resources, accountSkinFace); + mSkinHeadCache.put(headCacheHash, accountHead); + } + + textview.setText(account.username); + textview.setCompoundDrawablesRelative(accountHead, null, authType, null); + } + + private void showDeleteDialog(Context context, int position) { + new AlertDialog.Builder(context) + .setMessage(R.string.warning_remove_account) + .setPositiveButton(android.R.string.cancel, null) + .setNeutralButton(R.string.global_delete, (dialog, which) -> { + MinecraftAccount account = getItem(position); + Accounts.delete(account); + reload(); + }) + .show(); + } + } +} diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/MineButton.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/MineButton.java index 8d5c65f461..324498ab29 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/MineButton.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/MineButton.java @@ -3,26 +3,42 @@ import android.content.*; import android.graphics.*; import android.util.*; - +import android.view.*; +import android.view.animation.*; import androidx.core.content.res.ResourcesCompat; - import net.kdt.pojavlaunch.R; public class MineButton extends androidx.appcompat.widget.AppCompatButton { - - public MineButton(Context ctx) { - this(ctx, null); - } - - public MineButton(Context ctx, AttributeSet attrs) { - super(ctx, attrs); - init(); - } - public void init() { - setTypeface(ResourcesCompat.getFont(getContext(), R.font.noto_sans_bold)); - setBackground(ResourcesCompat.getDrawable(getResources(), R.drawable.mine_button_background, null)); - setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimensionPixelSize(R.dimen._13ssp)); - } + public MineButton(Context ctx) { + this(ctx, null); + } + + public MineButton(Context ctx, AttributeSet attrs) { + super(ctx, attrs); + init(); + } + + public void init() { + setTypeface(ResourcesCompat.getFont( + getContext(), R.font.noto_sans_bold)); + setBackground(ResourcesCompat.getDrawable( + getResources(), + R.drawable.mine_button_background, getContext().getTheme())); + setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimensionPixelSize(R.dimen._13ssp)); + setTextColor(Color.WHITE); -} + setOnTouchListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + animate().scaleX(0.95f).scaleY(0.95f) + .setDuration(100).start(); + } else if (event.getAction() == MotionEvent.ACTION_UP + || event.getAction() == MotionEvent.ACTION_CANCEL) { + animate().scaleX(1f).scaleY(1f) + .setDuration(100).start(); + } + return false; + }); + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/TextProgressBar.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/TextProgressBar.java index db1862ebcd..8993354fcc 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/TextProgressBar.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/TextProgressBar.java @@ -34,7 +34,7 @@ public TextProgressBar(Context context, AttributeSet attrs, int defStyleAttr, in private String mText = ""; private void init(){ - setProgressDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.view_text_progressbar, null)); + setProgressDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.view_text_progressbar, getContext().getTheme())); setProgress(35); mTextPaint = new Paint(); mTextPaint.setColor(Color.WHITE); @@ -65,4 +65,4 @@ public final void setText(String text){ public final void setTextPadding(int padding){ mTextPadding = padding; } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java index 0c576ade2f..b3031e725c 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java @@ -9,6 +9,7 @@ import android.graphics.Paint; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.net.NetworkInfo; import android.net.Uri; import android.util.AttributeSet; import android.util.Log; @@ -47,6 +48,9 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Objects; + +import android.util.TypedValue; import fr.spse.extended_view.ExtendedTextView; @@ -123,7 +127,7 @@ public mcAccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs) /* Triggered when we need to do microsoft login */ private final ExtraListener mMicrosoftLoginListener = (key, value) -> { - mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color)); + mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent)); new MicrosoftBackgroundLogin(false, value.getQueryParameter("code")).performLogin( mProgressListener, mDoneListener, mErrorListener); return false; @@ -149,8 +153,8 @@ public mcAccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs) @SuppressLint("ClickableViewAccessibility") private void init(){ // Set visual properties - setBackgroundColor(getResources().getColor(R.color.background_status_bar)); - mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color)); + setBackgroundColor(getThemeColor(net.kdt.pojavlaunch.R.attr.colorBgStatusBar)); + mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent)); mLoginBarPaint.setStrokeWidth(getResources().getDimensionPixelOffset(R.dimen._2sdp)); // Set behavior @@ -276,9 +280,13 @@ private void reloadAccounts(boolean fromFiles, int overridePosition){ } private void performLogin(MinecraftAccount minecraftAccount){ + // Logging in when there's no internet is useless. This should really be turned into a network callback though. + if(!Tools.isOnline(getContext())){ + return; + } if(minecraftAccount.isLocal()) return; - mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color)); + mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent)); if(minecraftAccount.isMicrosoft){ if(System.currentTimeMillis() > minecraftAccount.expiresAt){ // Perform login only if needed @@ -296,14 +304,23 @@ private void pickAccount(int position){ PojavProfile.setCurrentProfile(getContext(), mAccountList.get(position)); selectedAccount = PojavProfile.getCurrentProfileContent(getContext(), mAccountList.get(position)); - // WORKAROUND // Account file corrupted due to previous versions having improper encoding if (selectedAccount == null){ - removeCurrentAccount(); - pickAccount(-1); - setSelection(0); - return; + Context ctx = Objects.requireNonNull(getContext()); + + new AlertDialog.Builder(ctx) + .setCancelable(false) + .setTitle(R.string.account_corrupted) + .setMessage(R.string.login_again) + .setPositiveButton(R.string.delete_account_and_login, (dialog, which) -> { + removeCurrentAccount(); + pickAccount(-1); + setSelection(0); + }) + .show(); + + } setSelection(position); }else { @@ -405,6 +422,10 @@ private void showDeleteDialog(Context context, int position) { } } - - -} + /** Resolve a theme colour attribute (e.g. R.attr.copperAccent) to an int colour. */ + private int getThemeColor(int attr) { + TypedValue tv = new TypedValue(); + getContext().getTheme().resolveAttribute(attr, tv, true); + return tv.data; + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java index 50dfd75bac..a3fd5f599d 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java @@ -92,6 +92,19 @@ public void openProfileEditor(FragmentActivity fragmentActivity) { /** Reload profiles from the file, forcing the spinner to consider the new data */ public void reloadProfiles(){ mProfileAdapter.reloadProfiles(); + // Re-apply selection so the spinner redraws the icon at the correct size. + // Also handles deletion: REFRESH_VERSION_SPINNER extra is consumed here. + String extra_value = (String) ExtraCore.consumeValue(ExtraConstants.REFRESH_VERSION_SPINNER); + int newIndex; + if (extra_value != null) { + newIndex = extra_value.equals(DELETED_PROFILE) ? 0 + : Math.max(0, getProfileAdapter().resolveProfileIndex(extra_value)); + } else { + newIndex = Math.max(0, mProfileAdapter.resolveProfileIndex( + LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, ""))); + } + setProfileSelection(newIndex); } /** Initialize various behaviors */ @@ -198,4 +211,4 @@ private void hidePopup(boolean animate) { public ProfileAdapter getProfileAdapter() { return mProfileAdapter; } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java index 8716525d47..1bacc785e9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java @@ -103,5 +103,17 @@ public static String archAsString(int arch){ if(arch == ARCH_X86) return "x86"; return "UNSUPPORTED_ARCH"; } + /** + * Convert to a string an architecture. + * @param arch The architecture as an int. + * @return "arm64" || "arm" || "x86_64" || "x86" || "UNSUPPORTED_ARCH" + */ + public static String archAsStringAndroid(int arch) { + if(arch == ARCH_ARM64) return "arm64-v8a"; + if(arch == ARCH_ARM) return "armeabi-v7a"; + if(arch == ARCH_X86_64) return "x86_64"; + if(arch == ARCH_X86) return "x86"; + return "UNSUPPORTED_ARCH"; + } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java new file mode 100644 index 0000000000..85d3be99e3 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java @@ -0,0 +1,136 @@ +package net.kdt.pojavlaunch; + +import android.content.Intent; +import android.net.Uri; +import android.view.Choreographer; +import android.view.KeyEvent; + +import androidx.annotation.Keep; + +import net.kdt.pojavlaunch.lifecycle.ContextExecutor; + +import java.io.File; + +import git.artdeell.dnbootstrap.glfw.GLFW; + +public class CallbackBridge { + public static final Choreographer sChoreographer = Choreographer.getInstance(); + + public static volatile int windowWidth, windowHeight; + public volatile static boolean holdingAlt, holdingCapslock, holdingCtrl, + holdingNumlock, holdingShift; + + public static void performClick(int button) { + double ox = GLFW.cursorX, oy = GLFW.cursorY; + GLFW.sendMouseEvent(button, 1, CallbackBridge.getCurrentMods()); + sChoreographer.postFrameCallbackDelayed(l -> { + GLFW.cursorX = ox; + GLFW.cursorY = oy; + GLFW.sendMouseEvent(button, 0, CallbackBridge.getCurrentMods()); + }, 33); + } + + + public static void sendKeyPress(int keyCode) { + GLFW.sendKeyEvent(keyCode, true, getCurrentMods()); + GLFW.sendKeyEvent(keyCode, false, getCurrentMods()); + } + + public static void sendMouseButton(int button, boolean status) { + CallbackBridge.sendMouseKeycode(button, CallbackBridge.getCurrentMods(), status); + } + + public static void sendMouseKeycode(int button, int modifiers, boolean isDown) { + GLFW.sendMouseEvent(button, isDown ? 1 : 0, modifiers); + } + + public static void sendScroll(double xoffset, double yoffset) { + GLFW.sendScrollEvent(xoffset, yoffset); + } + + public static int getCurrentMods() { + int currMods = 0; + if (holdingAlt) { + currMods |= LwjglGlfwKeycode.GLFW_MOD_ALT; + } if (holdingCapslock) { + currMods |= LwjglGlfwKeycode.GLFW_MOD_CAPS_LOCK; + } if (holdingCtrl) { + currMods |= LwjglGlfwKeycode.GLFW_MOD_CONTROL; + } if (holdingNumlock) { + currMods |= LwjglGlfwKeycode.GLFW_MOD_NUM_LOCK; + } if (holdingShift) { + currMods |= LwjglGlfwKeycode.GLFW_MOD_SHIFT; + } + return currMods; + } + + public static void setModifiers(KeyEvent keyEvent) { + CallbackBridge.holdingAlt = keyEvent.isAltPressed(); + CallbackBridge.holdingCapslock = keyEvent.isCapsLockOn(); + CallbackBridge.holdingCtrl = keyEvent.isCtrlPressed(); + CallbackBridge.holdingNumlock = keyEvent.isNumLockOn(); + CallbackBridge.holdingShift = keyEvent.isShiftPressed(); + } + + public static void setModifiers(int keyCode, boolean isDown){ + switch (keyCode){ + case LwjglGlfwKeycode.GLFW_KEY_LEFT_SHIFT: + CallbackBridge.holdingShift = isDown; + return; + + case LwjglGlfwKeycode.GLFW_KEY_LEFT_CONTROL: + CallbackBridge.holdingCtrl = isDown; + return; + + case LwjglGlfwKeycode.GLFW_KEY_LEFT_ALT: + CallbackBridge.holdingAlt = isDown; + return; + + case LwjglGlfwKeycode.GLFW_KEY_CAPS_LOCK: + CallbackBridge.holdingCapslock = isDown; + return; + + case LwjglGlfwKeycode.GLFW_KEY_NUM_LOCK: + CallbackBridge.holdingNumlock = isDown; + } + } + + @Keep + public static void openLink(String link) { + ContextExecutor.executeActivity(ctx->{ + try { + if(link.startsWith("file:")) { + int truncLength = 5; + if(link.startsWith("file://")) truncLength = 7; + String path = link.substring(truncLength); + Tools.openPath(ctx, new File(path), false); + }else { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.parse(link), "*/*"); + ctx.startActivity(intent); + } + } catch (Throwable th) { + Tools.showError(ctx, th); + } + }); + } + + @SuppressWarnings("unused") //TODO: actually use it + public static void openPath(String path) { + ContextExecutor.executeActivity(ctx->{ + try { + Tools.openPath(ctx, new File(path), false); + } catch (Throwable th) { + Tools.showError(ctx, th); + } + }); + } + + public static native void minibridgeInit(); + + static { + System.loadLibrary("pojavexec"); + minibridgeInit(); + } +} + diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java index 5fce0dcbc4..47af3319fd 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java @@ -15,6 +15,7 @@ public class EfficientAndroidLWJGLKeycode { //The value its LWJGL equivalent. private static final int KEYCODE_COUNT = 106; private static final int[] sAndroidKeycodes = new int[KEYCODE_COUNT]; + private static final int[] sLwjglKeycodesReversed = new int[LwjglGlfwKeycode.GLFW_KEY_LAST]; private static final short[] sLwjglKeycodes = new short[KEYCODE_COUNT]; private static String[] androidKeyNameArray; /* = new String[androidKeycodes.length]; */ private static int mTmpCount = 0; @@ -198,6 +199,28 @@ public static void execKeyIndex(int index){ sendKeyPress(getValueByIndex(index)); } + /** + * Takes a GLFW keycode and returns its char primitive. Works with Shift/Caps Lock. + *

+ * Non-letter characters return U+0000. + * + * @param lwjglGlfwKeycode A GLFW key code macro (e.g., {@link LwjglGlfwKeycode#GLFW_KEY_W}). + */ + public static char getLwjglChar(int lwjglGlfwKeycode){ + int androidKeycode = sAndroidKeycodes[sLwjglKeycodesReversed[lwjglGlfwKeycode]]; + KeyEvent key = new KeyEvent(KeyEvent.ACTION_UP, androidKeycode); + char charToSend; + charToSend = ((char) key.getUnicodeChar()); + int currentMods = CallbackBridge.getCurrentMods(); + if (Character.isLetter(charToSend) && ( + ((currentMods & LwjglGlfwKeycode.GLFW_MOD_SHIFT) != 0) ^ + ((currentMods & LwjglGlfwKeycode.GLFW_MOD_CAPS_LOCK) != 0)) + ){ + charToSend = Character.toUpperCase(charToSend); + } + return charToSend; + } + public static short getValueByIndex(int index) { return sLwjglKeycodes[index]; } @@ -218,6 +241,7 @@ public static int getIndexByValue(int lwjglKey) { private static void add(int androidKeycode, short LWJGLKeycode){ sAndroidKeycodes[mTmpCount] = androidKeycode; sLwjglKeycodes[mTmpCount] = LWJGLKeycode; + sLwjglKeycodesReversed[LWJGLKeycode] = mTmpCount; mTmpCount ++; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java new file mode 100644 index 0000000000..51d4a89c4d --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java @@ -0,0 +1,81 @@ +package net.kdt.pojavlaunch; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Insets; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +@RequiresApi(29) +public class InsetBackground extends Drawable { + private final Rect mLeftRect = new Rect(); + private final Rect mTopRect = new Rect(); + private final Rect mRightRect = new Rect(); + private final Rect mBottomRect = new Rect(); + private final Paint mRectPaint = new Paint(); + private final Insets mInsets; + + public InsetBackground(Insets insets, int bgColor) { + Log.i("InsetBackground", insets.toString()); + mInsets = insets; + mRectPaint.setColor(bgColor); + } + + private void computeRects(int width, int height) { + mLeftRect.left = 0; + mLeftRect.right = mInsets.left; + mLeftRect.top = 0; + mLeftRect.bottom = height; + + mTopRect.left = mInsets.left; + mTopRect.right = width - mInsets.right; + mTopRect.top = 0; + mTopRect.bottom = mInsets.top; + + mRightRect.left = width - mInsets.right; + mRightRect.right = width; + mRightRect.top = 0; + mRightRect.bottom = height; + + mBottomRect.left = 0; + mBottomRect.right = width; + mBottomRect.top = height - mInsets.bottom; + mBottomRect.bottom = height; + } + + @Override + protected void onBoundsChange(@NonNull Rect bounds) { + computeRects(bounds.width(), bounds.height()); + invalidateSelf(); + } + + @Override + public void draw(@NonNull Canvas canvas) { + canvas.drawRect(mLeftRect, mRectPaint); + canvas.drawRect(mRightRect, mRectPaint); + canvas.drawRect(mTopRect, mRectPaint); + canvas.drawRect(mBottomRect, mRectPaint); + } + + @Override + public void setAlpha(int alpha) { + + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSPARENT; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java index 6db1ccbf2f..d96967a915 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch; +import static net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF; + import android.annotation.SuppressLint; import android.app.ProgressDialog; import android.content.ClipboardManager; @@ -242,8 +244,8 @@ private void startModInstaller(File modFile, String javaArgs) { selectedMod = findModPath(argList); } Runtime selectedRuntime; - if(selectedMod == null) { - // We were unable to find out the path to the mod. In that case, use the default runtime. + if(selectedMod == null || DEFAULT_PREF.getBoolean("disable_autojre_select", false)) { + // If we are unable to find out the path to the mod or the user explicitly desires so, we use the default runtime selectedRuntime = MultiRTUtils.forceReread(LauncherPreferences.PREF_DEFAULT_RUNTIME); }else { // Autoselect it properly in the other case. @@ -355,9 +357,8 @@ public void launchJavaRuntime(Runtime runtime, File modFile, List javaAr JREUtils.redirectAndPrintJRELog(); try { List javaArgList = new ArrayList<>(); - // Enable Caciocavallo - Tools.getCacioJavaArgs(javaArgList,runtime.javaVersion == 8); + Tools.getCacioJavaArgs(javaArgList,runtime.javaVersion == 8, this); if(javaArgs != null) { javaArgList.addAll(javaArgs); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java index 44a873b47c..630f359f89 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java @@ -1,12 +1,15 @@ package net.kdt.pojavlaunch; import static android.content.res.Configuration.ORIENTATION_PORTRAIT; +import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; + import android.Manifest; import android.app.NotificationManager; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; +import android.util.Log; import android.view.View; import android.widget.ImageButton; import android.widget.Toast; @@ -34,6 +37,10 @@ import net.kdt.pojavlaunch.lifecycle.ContextAwareDoneListener; import net.kdt.pojavlaunch.lifecycle.ContextExecutor; import net.kdt.pojavlaunch.modloaders.modpacks.ModloaderInstallTracker; +import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModLoader; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackInstaller; +import net.kdt.pojavlaunch.modloaders.modpacks.api.NotificationDownloadListener; import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.IconCacheJanitor; import net.kdt.pojavlaunch.prefs.LauncherPreferences; import net.kdt.pojavlaunch.prefs.screens.LauncherPreferenceFragment; @@ -43,11 +50,15 @@ import net.kdt.pojavlaunch.tasks.AsyncMinecraftDownloader; import net.kdt.pojavlaunch.tasks.AsyncVersionList; import net.kdt.pojavlaunch.tasks.MinecraftDownloader; +import net.kdt.pojavlaunch.utils.DateUtils; import net.kdt.pojavlaunch.utils.NotificationUtils; import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; +import java.io.IOException; import java.lang.ref.WeakReference; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; public class LauncherActivity extends BaseActivity { public static final String SETTING_FRAGMENT_TAG = "SETTINGS_FRAGMENT"; @@ -56,6 +67,25 @@ public class LauncherActivity extends BaseActivity { registerForActivityResult(new OpenDocumentWithExtension("jar"), (data)->{ if(data != null) Tools.launchModInstaller(this, data); }); + public final ActivityResultLauncher modpackImportLauncher = + registerForActivityResult(new OpenDocumentWithExtension(new String[]{"zip", "mrpack"}), (data)->{ + if(data != null) { + PojavApplication.sExecutorService.execute(() -> { + try { + ModLoader loaderInfo = new CommonApi(getString(R.string.curseforge_api_key)).importModpack(this, data); + if (loaderInfo == null) return; + loaderInfo.getDownloadTask(new NotificationDownloadListener(this, loaderInfo)).run(); + } catch (IOException e) { + Tools.showErrorRemote(this, R.string.modpack_install_download_failed, e); + } catch (IllegalArgumentException e) { + Tools.showError(this, R.string.not_modpack_file, e); + } catch (NoSuchAlgorithmException e) { + // Should literally never happen because SHA-1 is required Java spec + throw new RuntimeException(e); + } + }); + } + }); private mcAccountSpinner mAccountSpinner; private FragmentContainerView mFragmentView; @@ -86,17 +116,30 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) // Allow starting the add account only from the main menu, should it be moved to fragment itself ? if(!(fragment instanceof MainMenuFragment)) return false; - Tools.swapFragment(this, SelectAuthFragment.class, SelectAuthFragment.TAG, null); + // In landscape two-pane mode, load into right pane; otherwise full-screen swap + MainMenuFragment mmf = (MainMenuFragment) fragment; + if (!mmf.tryOpenInRightPane(SelectAuthFragment.class, SelectAuthFragment.TAG, null)) { + Tools.swapFragment(this, SelectAuthFragment.class, SelectAuthFragment.TAG, null); + } return false; }; /* Listener for the settings fragment */ private final View.OnClickListener mSettingButtonListener = v -> { Fragment fragment = getSupportFragmentManager().findFragmentById(mFragmentView.getId()); - if(fragment instanceof MainMenuFragment){ - Tools.swapFragment(this, LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null); - } else{ - // The setting button doubles as a home button now + if (fragment instanceof MainMenuFragment) { + MainMenuFragment mmf = (MainMenuFragment) fragment; + // In two-pane landscape: if right pane already has content, pressing the + // gear/home button pops back to home. If pane is at home, open settings. + if (mmf.isRightPaneActive()) { + mmf.clearRightPane(); + } else { + if (!mmf.tryOpenInRightPane(LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null)) { + Tools.swapFragment(this, LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null); + } + } + } else { + // Portrait: the settings button doubles as a home button when not on main menu Tools.backToMainMenu(this); } }; @@ -125,6 +168,23 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) } String normalizedVersionId = AsyncMinecraftDownloader.normalizeVersionId(prof.lastVersionId); JMinecraftVersionList.Version mcVersion = AsyncMinecraftDownloader.getListedVersion(normalizedVersionId); + + // Do not load when is a modded version or older than minecraft 1.3 on demo account + if (mAccountSpinner.getSelectedAccount().isDemo()) { + boolean isOlderThan13 = true; + + if (mcVersion != null) { + try { + isOlderThan13 = DateUtils.dateBefore(DateUtils.parseReleaseDate(mcVersion.releaseTime), 2012, 6, 22); + } catch (ParseException ignored) {} + } + + if (isOlderThan13) { + hasNoOnlineProfileDialog(this, getString(R.string.global_error), getString(R.string.demo_versions_supported)); + return false; + } + } + new MinecraftDownloader().start( this, mcVersion, @@ -145,7 +205,9 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) }; private ActivityResultLauncher mRequestNotificationPermissionLauncher; + private ActivityResultLauncher mRequestMicrophonePermissionLauncher; private WeakReference mRequestNotificationPermissionRunnable; + private WeakReference mRequestMicrophonePermissionRunnable; @Override protected boolean shouldIgnoreNotch() { @@ -160,6 +222,12 @@ public boolean setFullscreen() { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + // Apply saved colour theme before layout inflation + setTheme(net.kdt.pojavlaunch.theme.ThemeManager.getSavedTheme()); + // Apply force-landscape preference before layout inflation + if (LauncherPreferences.DEFAULT_PREF.getBoolean("force_landscape", false)) { + setRequestedOrientation(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); + } setContentView(R.layout.activity_pojav_launcher); FragmentManager fragmentManager = getSupportFragmentManager(); // If we don't have a back stack root yet... @@ -185,6 +253,16 @@ protected void onCreate(Bundle savedInstanceState) { } } ); + mRequestMicrophonePermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + isAllowed -> { + if(!isAllowed) handleNoNotificationPermission(); + else { + Runnable runnable = Tools.getWeakReference(mRequestMicrophonePermissionRunnable); + if(runnable != null) runnable.run(); + } + } + ); getWindow().setBackgroundDrawable(null); bindViews(); checkNotificationPermission(); @@ -254,6 +332,18 @@ public void onBackPressed() { } } + // In landscape two-pane mode: if the right pane has content, pop it instead of exiting + Fragment rootFrag = getVisibleFragment("ROOT"); + if (rootFrag instanceof MainMenuFragment) { + MainMenuFragment mmf = (MainMenuFragment) rootFrag; + if (mmf.isRightPaneActive()) { + mmf.popRightPane(); + return; + } + finish(); + return; + } + // Check if we are at the root then if(getVisibleFragment("ROOT") != null){ finish(); @@ -322,6 +412,11 @@ public boolean checkForNotificationPermission() { this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_DENIED; } + public boolean checkForMicrophonePermission() { + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_DENIED; + } public void askForNotificationPermission(Runnable onSuccessRunnable) { if(Build.VERSION.SDK_INT < 33) return; @@ -331,6 +426,13 @@ public void askForNotificationPermission(Runnable onSuccessRunnable) { mRequestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS); } + public void askForMicrophonePermission(Runnable onSuccessRunnable) { + if(onSuccessRunnable != null) { + mRequestMicrophonePermissionRunnable = new WeakReference<>(onSuccessRunnable); + } + mRequestMicrophonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO); + } + /** Stuff all the view boilerplate here */ private void bindViews(){ mFragmentView = findViewById(R.id.container_fragment); @@ -338,4 +440,4 @@ private void bindViews(){ mAccountSpinner = findViewById(R.id.account_spinner); mProgressLayout = findViewById(R.id.progress_layout); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java index bcd5fb4776..d2e41f8744 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java @@ -2,24 +2,52 @@ import androidx.annotation.Keep; +import java.util.concurrent.CopyOnWriteArrayList; + /** Singleton class made to log on one file * The singleton part can be removed but will require more implementation from the end-dev */ @Keep public class Logger { + private static final CopyOnWriteArrayList logListeners = new CopyOnWriteArrayList<>(); + /** Print the text to the log file if not censored */ public static native void appendToLog(String text); - /** Reset the log file, effectively erasing any previous logs */ public static native void begin(String logFilePath); - /** Small listener for anything listening to the log */ + /** Add a listener for the logfile, ask the native side for a listener if needed */ + public static void addLogListener(eventLogListener logListeners) { + boolean wasEmpty = Logger.logListeners.isEmpty(); + Logger.logListeners.add(logListeners); + if (wasEmpty) setLogListener(Logger::onEventLogged); + } + + /** Remove a listener for the logfile, unset the native listener if no listeners left */ + public static void removeLogListener(eventLogListener logListener) { + Logger.logListeners.remove(logListener); + if (Logger.logListeners.isEmpty()){ + // Makes the JNI code be able to skip expensive logger callbacks + // NOTE: was tested by rapidly smashing the log on/off button, no sync issues found :) + setLogListener(null); + } + } + + private static void onEventLogged(String text) { + for (eventLogListener logListener: Logger.logListeners) { + logListener.onEventLogged(text); + } + } + + /** Small listener for anything listening to the log + * Performs double duty as being the interface for java listeners and the native callback + */ @Keep public interface eventLogListener { void onEventLogged(String text); } /** Link a log listener to the logger */ - public static native void setLogListener(eventLogListener logListener); + private static native void setLogListener(eventLogListener logListener); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java index 1ba8d6d4c9..7cfdb40713 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java @@ -197,6 +197,7 @@ public class LwjglGlfwKeycode { GLFW_MOUSE_BUTTON_MIDDLE = GLFW_MOUSE_BUTTON_3; public static final int + GLFW_FOCUSED = 0x20001, GLFW_VISIBLE = 0x20004, GLFW_HOVERED = 0x2000B; } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java index 42052ce201..996edf58a8 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java @@ -1,6 +1,9 @@ package net.kdt.pojavlaunch; import static net.kdt.pojavlaunch.Tools.currentDisplayMetrics; +import static net.kdt.pojavlaunch.Tools.dialogForceClose; +import static net.kdt.pojavlaunch.Tools.hasMods; +import static net.kdt.pojavlaunch.Tools.runMethodbyReflection; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_ENABLE_GYRO; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_SUSTAINED_PERFORMANCE; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_USE_ALTERNATE_SURFACE; @@ -24,13 +27,11 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.provider.DocumentsContract; import android.util.Log; import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; -import android.webkit.MimeTypeMap; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.ListView; @@ -62,20 +63,26 @@ import net.kdt.pojavlaunch.services.GameService; import net.kdt.pojavlaunch.utils.JREUtils; import net.kdt.pojavlaunch.utils.MCOptionUtils; +import net.kdt.pojavlaunch.utils.TouchControllerUtils; import net.kdt.pojavlaunch.value.MinecraftAccount; import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; +import org.libsdl.app.SDL; +import org.libsdl.app.SDLSurface; import org.lwjgl.glfw.CallbackBridge; import java.io.File; import java.io.IOException; +import java.util.Objects; public class MainActivity extends BaseActivity implements ControlButtonMenuListener, EditorExitable, ServiceConnection { public static volatile ClipboardManager GLOBAL_CLIPBOARD; + public static final String TAG = "MainActivity"; public static final String INTENT_MINECRAFT_VERSION = "intent_version"; volatile public static boolean isInputStackCall; + protected static View.OnGenericMotionListener motionListener = (v, event) -> false; public static TouchCharInput touchCharInput; private MinecraftGLSurface minecraftGLView; @@ -101,8 +108,43 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU) { + // TODO: Use lower level HID capture that needs a dialogue box from the user for the + // app to fully take focus of the input devices. Might cause issues with older android + // versions so we don't use that right now. Needs testing. + // Currently tried but only identification works OOTB, inputs aren't being sent. + + // TODO: Use a hook to load SDL logic depending on whether libSDL3.so is loaded. + try { + // Note: This doesn't dlopen it for the mod, they still have to do it themselves + // Why? https://github.com/android/ndk/issues/201#issuecomment-248060092 + // Just in case that gets deleted off the internet: + // "On Android only the main executable and LD_PRELOADs are considered to be + // RTLD_GLOBAL, all the dependencies of the main executable remain RTLD_LOCAL." - dimitry + SDL.loadLibrary("SDL3", this); + SDL.loadLibrary("SDL2", this); + SDL.initialize(); + SDL.setupJNI(); + SDL.setContext(this); + new SDLSurface(this); + motionListener = (View.OnGenericMotionListener) + runMethodbyReflection("org.libsdl.app.SDLActivity", + "getMotionListener"); + if (LauncherPreferences.PREF_GAMEPAD_FORCEDSDL_PASSTHRU) Tools.SDL.initializeControllerSubsystems(); + } catch (UnsatisfiedLinkError ignored) { + // Ignore because if SDL.setupJNI(); fails, SDL wasn't loaded. + } catch (ReflectiveOperationException e) { + Tools.showErrorRemote("SDL did not load properly.", e); + } + } + minecraftProfile = LauncherProfiles.getCurrentProfile(); - MCOptionUtils.load(Tools.getGameDirPath(minecraftProfile).getAbsolutePath()); + + String gameDirPath = Tools.getGameDirPath(minecraftProfile).getAbsolutePath(); + MCOptionUtils.load(gameDirPath); + if (Tools.hasTouchController(new File(gameDirPath)) || LauncherPreferences.PREF_FORCE_ENABLE_TOUCHCONTROLLER) { + TouchControllerUtils.initialize(this); + } Intent gameServiceIntent = new Intent(this, GameService.class); // Start the service a bit early @@ -167,6 +209,9 @@ protected void initLayout(int resId) { if(minecraftProfile.pojavRendererName != null) { Log.i("RdrDebug","__P_renderer="+minecraftProfile.pojavRendererName); Tools.LOCAL_RENDERER = minecraftProfile.pojavRendererName; + // TODO: Remove this jank when it's not relevant anymore + // Shitty hack to make OSMZink smoothly transition into kopper + if (minecraftProfile.pojavRendererName.equals("vulkan_zink")) Tools.LOCAL_RENDERER = "opengles3_desktopgl_zink_kopper"; } setTitle("Minecraft " + minecraftProfile.lastVersionId); @@ -190,10 +235,11 @@ protected void initLayout(int resId) { android.R.layout.simple_list_item_1, getResources().getStringArray(R.array.menu_ingame)); gameActionClickListener = (parent, view, position, id) -> { switch(position) { - case 0: openLogOutput(); break; - case 1: dialogSendCustomKey(); break; - case 2: openQuickSettings(); break; - case 3: openCustomControls(); break; + case 0: dialogForceClose(MainActivity.this); break; + case 1: openLogOutput(); break; + case 2: dialogSendCustomKey(); break; + case 3: openQuickSettings(); break; + case 4: openCustomControls(); break; } drawerLayout.closeDrawers(); }; @@ -268,6 +314,7 @@ private void bindValues(){ public void onResume() { super.onResume(); if(PREF_ENABLE_GYRO) mGyroControl.enable(); + CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, 1); CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_HOVERED, 1); } @@ -280,7 +327,9 @@ protected void onPause() { if(mQuickSettingSideDialog != null) { mQuickSettingSideDialog.cancel(); } + CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, 0); CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_HOVERED, 0); + super.onPause(); } @@ -307,7 +356,6 @@ protected void onDestroy() { @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); - if(mGyroControl != null) mGyroControl.updateOrientation(); // Layout resize is practically guaranteed on a configuration change, and `onConfigurationChanged` // does not implicitly start a layout. So, request a layout and expect the screen dimensions to be valid after the] @@ -315,6 +363,7 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { mControlLayout.requestLayout(); mControlLayout.post(()->{ // Child of mControlLayout, so refreshing size here is correct + Tools.setFullscreen(this, setFullscreen()); minecraftGLView.refreshSize(); Tools.updateWindowSize(this); mControlLayout.refreshControlButtonPositions(); @@ -347,19 +396,65 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } private void runCraft(String versionId, JMinecraftVersionList.Version version) throws Throwable { - if(Tools.LOCAL_RENDERER == null) { - Tools.LOCAL_RENDERER = LauncherPreferences.PREF_RENDERER; + String assetVersion; + try { + if (version.inheritsFrom != null) { // We are almost definitely modded if this runs + File vanillaJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + version.inheritsFrom + "/" + version.inheritsFrom + ".json"); + JMinecraftVersionList.Version vanillaJson; + try { // Get the vanilla json from modded instance + vanillaJson = Tools.GLOBAL_GSON.fromJson(Tools.read(vanillaJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class); + } catch (IOException ignored) { // Should never happen, we check for this in MinecraftDownloader().start() + throw new RuntimeException(getString(R.string.error_vanilla_json_corrupt)); + } + // Something went wrong if this is somehow not the case anymore + if (!Objects.equals(vanillaJson.assets, vanillaJson.assetIndex.id)) + Tools.showErrorRemote(new RuntimeException(getString(R.string.error_vanilla_json_corrupt))); + assetVersion = vanillaJson.assets; + } else { + // Else assume we are vanilla + if (!Objects.equals(version.assets, version.assetIndex.id)) + Tools.showErrorRemote(new RuntimeException(getString(R.string.error_vanilla_json_corrupt))); + assetVersion = version.assets; + } + } catch (RuntimeException ignored){ + assetVersion = "legacy"; + } // If this fails.. oh well. + + // FIXME: Automatic detection should be based on provided hint GLFW_CONTEXT_VERSION_MAJOR and GLFW_CONTEXT_VERSION_MINOR + // Autoselect renderer + if (Tools.LOCAL_RENDERER == null) { + // Preferably we could detect when it is modded and swap to zink however that would also + // cover optifine and vanilla+ configurations which are relatively common, degrading their + // experience for no reason. We will compromise with just having users do it themselves. + Tools.LOCAL_RENDERER = "opengles2"; + // MobileGlues becomes available post 1.17. It has superior compatibility with mods + // while having fairly similar performance compared to GL4ES-based forks. + if(assetVersion.matches("\\d+") || // Should match all digits, which is the modern assetVersioning + "1.17".equals(assetVersion) || + "1.18".equals(assetVersion) || + "1.19".equals(assetVersion) || + // Angelica gives us GL3.3core on 1.7.10, it's a unique case. + hasMods("angelica")) Tools.LOCAL_RENDERER = "opengles_mobileglues"; } if(!Tools.checkRendererCompatible(this, Tools.LOCAL_RENDERER)) { Tools.RenderersList renderersList = Tools.getCompatibleRenderers(this); String firstCompatibleRenderer = renderersList.rendererIds.get(0); Log.w("runCraft","Incompatible renderer "+Tools.LOCAL_RENDERER+ " will be replaced with "+firstCompatibleRenderer); Tools.LOCAL_RENDERER = firstCompatibleRenderer; + runOnUiThread(() -> Toast.makeText(this, R.string.autorendererselectfailed, Toast.LENGTH_LONG).show()); Tools.releaseRenderersCache(); } + + // MCL-3732 Mitigation + // I don't trust the bug tracker. 'server-resource-pack" was removed in 1.20.3-pre3 + // so we use 12 to detect that. We still generate till 1.20.5 else we don't cover + // 1.20.3-pre2 and such. Better to over than to under. + File folder = new File(Tools.getGameDirPath(minecraftProfile), "server-resource-pack"); + try { + if (Integer.parseInt(assetVersion) <= 12) folder.mkdir(); + } catch (NumberFormatException e) { folder.mkdir(); } + MinecraftAccount minecraftAccount = PojavProfile.getCurrentProfileContent(this, null); - Logger.appendToLog("--------- Starting game with Launcher Debug!"); - Tools.printLauncherInfo(versionId, Tools.isValidString(minecraftProfile.javaArgs) ? minecraftProfile.javaArgs : LauncherPreferences.PREF_CUSTOM_JAVA_ARGS); JREUtils.redirectAndPrintJRELog(); LauncherProfiles.load(); int requiredJavaVersion = 8; @@ -573,4 +668,23 @@ public boolean dispatchTrackballEvent(MotionEvent ev) { return minecraftGLView.dispatchCapturedPointerEvent(ev); else return super.dispatchTrackballEvent(ev); } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (hasFocus) { + Tools.setFullscreen(this, setFullscreen()); + } + super.onWindowFocusChanged(hasFocus); + CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, hasFocus ? 1 : 0); + } + + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java index 5e84141d93..022b32690a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java @@ -1,6 +1,8 @@ package net.kdt.pojavlaunch; import static net.kdt.pojavlaunch.MainActivity.touchCharInput; +import static net.kdt.pojavlaunch.Tools.LOCAL_RENDERER; +import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE; import static net.kdt.pojavlaunch.utils.MCOptionUtils.getMcScale; import static org.lwjgl.glfw.CallbackBridge.sendMouseButton; import static org.lwjgl.glfw.CallbackBridge.windowHeight; @@ -13,6 +15,7 @@ import android.os.Build; import android.util.AttributeSet; import android.util.Log; +import android.view.Display; import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; @@ -39,9 +42,13 @@ import net.kdt.pojavlaunch.prefs.LauncherPreferences; import net.kdt.pojavlaunch.utils.JREUtils; import net.kdt.pojavlaunch.utils.MCOptionUtils; +import net.kdt.pojavlaunch.utils.TouchControllerUtils; +import org.libsdl.app.SDLActivity; +import org.libsdl.app.SDLControllerManager; import org.lwjgl.glfw.CallbackBridge; + import fr.spse.gamepad_remapper.GamepadHandler; import fr.spse.gamepad_remapper.RemapperManager; import fr.spse.gamepad_remapper.RemapperView; @@ -77,12 +84,15 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame final Object mSurfaceReadyListenerLock = new Object(); /* View holding the surface, either a SurfaceView or a TextureView */ View mSurface; + String TAG = "MinecraftGLSurface"; private final InGameEventProcessor mIngameProcessor = new InGameEventProcessor(mSensitivityFactor); private final InGUIEventProcessor mInGUIProcessor = new InGUIEventProcessor(); private TouchEventProcessor mCurrentTouchProcessor = mInGUIProcessor; private AndroidPointerCapture mPointerCapture; private boolean mLastGrabState = false; + public static boolean sdlEnabled = false; + boolean useSurfaceView = LauncherPreferences.PREF_USE_ALTERNATE_SURFACE; public MinecraftGLSurface(Context context) { this(context, null); @@ -92,6 +102,7 @@ public MinecraftGLSurface(Context context, AttributeSet attributeSet) { super(context, attributeSet); setFocusable(true); CallbackBridge.setDirectGamepadEnableHandler(this); + SDLControllerManager.setDirectGamepadEnableHandler(this); } @RequiresApi(api = Build.VERSION_CODES.O) @@ -109,7 +120,11 @@ private void setUpPointerCapture(AbstractTouchpad touchpad) { public void start(boolean isAlreadyRunning, AbstractTouchpad touchpad){ if(Tools.isAndroid8OrHigher()) setUpPointerCapture(touchpad); mInGUIProcessor.setAbstractTouchpad(touchpad); - if(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE){ + // Kopper Zink has orientation issues on SurfaceView + try { + useSurfaceView = useSurfaceView && !LOCAL_RENDERER.equals("opengles3_desktopgl_zink_kopper"); + } catch (NullPointerException ignored){} + if(useSurfaceView){ SurfaceView surfaceView = new SurfaceView(getContext()); mSurface = surfaceView; @@ -132,7 +147,15 @@ public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, } @Override - public void surfaceDestroyed(@NonNull SurfaceHolder holder) {} + public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + /* + Surface recreation in SurfaceView happens very often. When tabbing back in from + out, when minimizing floating window, when turning into floating window, etc. + Whenever the surface isn't in view, it is destroyed. When going into floating + window, it appears to automatically release the associated ANativeWindow. This + can cause a crash if not handled. + */ + } }); ((ViewGroup)getParent()).addView(surfaceView); @@ -163,6 +186,11 @@ public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surface, int wid @Override public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surface) { + /* + Surface recreation in TextureView can only really happen once, when turning + into a floating window. Subsequent turns to floating window no longer trigger + recreation. Tabbing out and in does not trigger recreation. + */ return true; } @@ -192,6 +220,13 @@ public boolean onTouchEvent(MotionEvent e) { if(toolType == MotionEvent.TOOL_TYPE_MOUSE) { if(Tools.isAndroid8OrHigher() && mPointerCapture != null) { + // Can't handleAutomaticCapture if mouse isn't captured + if (!CallbackBridge.isGrabbing() // Only capture if not in menu and user said so + && !PREF_MOUSE_GRAB_FORCE) { + // This returns true but we really can't consume this. + // Else we don't receive ACTION_MOVE + return !dispatchGenericMotionEvent(e); + } mPointerCapture.handleAutomaticCapture(); return true; } @@ -202,16 +237,17 @@ public boolean onTouchEvent(MotionEvent e) { CallbackBridge.sendCursorPos( e.getX(i) * LauncherPreferences.PREF_SCALE_FACTOR, e.getY(i) * LauncherPreferences.PREF_SCALE_FACTOR); return true; //mouse event handled successfully } + TouchControllerUtils.processTouchEvent(e, this); if (mIngameProcessor == null || mInGUIProcessor == null) return true; return mCurrentTouchProcessor.processTouchEvent(e); } private void createGamepad(View contextView, InputDevice inputDevice) { - if(CallbackBridge.sGamepadDirectInput) { + if(CallbackBridge.sGamepadDirectInput && !sdlEnabled) { mGamepadHandler = new DirectGamepad(); - }else { + }else if(!sdlEnabled) { mGamepadHandler = new Gamepad(contextView, inputDevice, DefaultDataProvider.INSTANCE, true); - } + }else mGamepadHandler = (code, value) -> {}; // Ensure it isn't null while also not processing the events. } /** @@ -220,9 +256,22 @@ private void createGamepad(View contextView, InputDevice inputDevice) { @SuppressLint("NewApi") @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { + if(sdlEnabled && Gamepad.isGamepadEvent(event)) { + final MotionEvent copy = MotionEvent.obtain(event); + PojavApplication.sExecutorService.execute(()->{ + try { + MainActivity.motionListener.onGenericMotion(this, copy); + copy.recycle(); + } catch (Throwable ignored) { + Log.e(TAG, "SDL failed to send motionevent!"); + } + }); + return true; + } + super.dispatchGenericMotionEvent(event); int mouseCursorIndex = -1; - if(Gamepad.isGamepadEvent(event)){ + if(!sdlEnabled && Gamepad.isGamepadEvent(event)){ if(mGamepadHandler == null) createGamepad(this, event.getDevice()); mInputManager.handleMotionEventInput(getContext(), event, mGamepadHandler); @@ -239,9 +288,9 @@ public boolean dispatchGenericMotionEvent(MotionEvent event) { // Make sure we grabbed the mouse if necessary updateGrabState(CallbackBridge.isGrabbing()); - switch(event.getActionMasked()) { case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_MOVE: CallbackBridge.mouseX = (event.getX(mouseCursorIndex) * LauncherPreferences.PREF_SCALE_FACTOR); CallbackBridge.mouseY = (event.getY(mouseCursorIndex) * LauncherPreferences.PREF_SCALE_FACTOR); CallbackBridge.sendCursorPos(CallbackBridge.mouseX, CallbackBridge.mouseY); @@ -293,8 +342,21 @@ public boolean processKeyEvent(KeyEvent event) { return true; } } - - if(Gamepad.isGamepadEvent(event)){ + // Android bundles in garbage KeyEvents for compatibility with old apps + // that don't have controller code so we are, checking for em. + boolean isGamepadEvent = Gamepad.isGamepadEvent(event); + if (sdlEnabled && isGamepadEvent) { + final KeyEvent copy = new KeyEvent(event); + PojavApplication.sExecutorService.execute(() -> { + try { + SDLActivity.handleKeyEvent(this, eventKeycode, copy, null); + } catch (Throwable ignored) { + Log.e(TAG, "SDL failed to send keyevent!"); + } + }); + return true; + } + if(!sdlEnabled && isGamepadEvent){ if(mGamepadHandler == null) createGamepad(this, event.getDevice()); mInputManager.handleKeyEventInput(getContext(), event, mGamepadHandler); @@ -358,7 +420,7 @@ public void refreshSize(boolean immediate) { Log.w("MGLSurface", "Attempt to refresh size on null surface"); return; } - if(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE){ + if(useSurfaceView){ SurfaceView view = (SurfaceView) mSurface; if(view.getHolder() != null){ view.getHolder().setFixedSize(windowWidth, windowHeight); @@ -378,6 +440,14 @@ private void realStart(Surface surface){ // Initial size set. Request immedate refresh, otherwise the initial width and height for the game // may be broken/unknown. refreshSize(true); + // Ensures we run at correct refresh rate (should also NOT change the resolution being used) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + float maxHz = 120f; // Set to 120 by default just to be safe + for (float altHz : getDisplay().getMode().getAlternativeRefreshRates()) { + maxHz = Math.max(maxHz, altHz); + } + surface.setFrameRate(maxHz, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT, Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS); + } //Load Minecraft options: MCOptionUtils.set("fullscreen", "off"); @@ -412,9 +482,10 @@ private TouchEventProcessor pickEventProcessor(boolean isGrabbing) { } private void updateGrabState(boolean isGrabbing) { - if(mLastGrabState != isGrabbing) { + TouchEventProcessor desiredProcessor = pickEventProcessor(isGrabbing); + if (mLastGrabState != isGrabbing || mCurrentTouchProcessor != desiredProcessor) { mCurrentTouchProcessor.cancelPendingActions(); - mCurrentTouchProcessor = pickEventProcessor(isGrabbing); + mCurrentTouchProcessor = desiredProcessor; mLastGrabState = isGrabbing; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java index 3b7f7c7580..76ca98ca16 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java @@ -1,17 +1,27 @@ package net.kdt.pojavlaunch; import static net.kdt.pojavlaunch.Architecture.archAsString; +import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture; +import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR; +import static net.kdt.pojavlaunch.Tools.isOnline; import android.app.Activity; +import android.content.Context; import android.content.res.AssetManager; import android.util.Log; +import com.kdt.mcgui.ProgressLayout; + import net.kdt.pojavlaunch.multirt.MultiRTUtils; import net.kdt.pojavlaunch.multirt.Runtime; +import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper; +import net.kdt.pojavlaunch.utils.DownloadUtils; import net.kdt.pojavlaunch.utils.MathUtils; import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -56,7 +66,7 @@ private static InternalRuntime getInternalRuntime(Runtime runtime) { } private static MathUtils.RankedValue getNearestInstalledRuntime(int targetVersion) { - List runtimes = MultiRTUtils.getRuntimes(); + List runtimes = MultiRTUtils.getInstalledRuntimes(); return MathUtils.findNearestPositive(targetVersion, runtimes, (runtime)->runtime.javaVersion); } @@ -84,7 +94,7 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion // Check whether the selection is an internal runtime InternalRuntime internalRuntime = getInternalRuntime(runtime); // If it is, check if updates are available from the APK file - if(internalRuntime != null) { + if (internalRuntime != null) { // Not calling showRuntimeFail on failure here because we did, technically, find the compatible runtime return checkInternalRuntime(assetManager, internalRuntime); } @@ -100,6 +110,18 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion nearestInternalRuntime, nearestInstalledRuntime, (value)->value.rank ); + // Check if the selected runtime actually exists in the APK, else download it + // If it isn't InternalRuntime then it wasn't in the apk in the first place! + if (selectedRankedRuntime.value instanceof InternalRuntime) + if (!checkInternalRuntime(assetManager, (InternalRuntime) selectedRankedRuntime.value)) { + if (nearestInstalledRuntime == null) // If this was non-null then it would be a valid runtime and we can leave it be + tryDownloadRuntime(activity, gameRequiredVersion); + // This means the internal runtime didn't extract so let's use installed instead + // This also refreshes it so after the runtime download, it can find the new runtime + selectedRankedRuntime = getNearestInstalledRuntime(gameRequiredVersion); + } + + // No possible selections if(selectedRankedRuntime == null) { showRuntimeFail(activity, versionInfo); @@ -141,9 +163,51 @@ private static void showRuntimeFail(Activity activity, JMinecraftVersionList.Ver activity.getString(R.string.multirt_nocompatiblert, verInfo.javaVersion.majorVersion)); } + public static boolean isJavaVersionAvailableForDownload(int version) { + for (ExternalRuntime javaVersion : ExternalRuntime.values()) { + if (javaVersion.majorVersion == version) { + return true; + } + } + return false; + } + + private static String getJreSource(int javaVersion, String arch){ + return String.format("https://github.com/AngelAuraMC/angelauramc-openjdk-build/releases/download/download_jre%1$s/jre%1$s-android-%2$s.tar.xz", javaVersion, arch); + } + /** + * @return whether installation was successful or not + */ + private static void tryDownloadRuntime(Context activity, int javaVersion){ + if (!isOnline(activity)) throw new RuntimeException(activity.getString(R.string.multirt_no_internet)); + String arch = archAsString(getDeviceArchitecture()); + // Checks for using this method + if (!isJavaVersionAvailableForDownload(javaVersion)) throw new RuntimeException("This is not an available JRE version"); + if ((getDeviceArchitecture() == Architecture.ARCH_X86 && javaVersion >= 21)) throw new RuntimeException("x86 is not supported on Java"+javaVersion); + try { + File outputFile = new File(Tools.DIR_CACHE, String.format("jre%s-android-%s.tar.xz", javaVersion, arch)); + DownloaderProgressWrapper monitor = new DownloaderProgressWrapper(R.string.newdl_downloading_jre_runtime, + ProgressLayout.UNPACK_RUNTIME); + monitor.extraString = Integer.toString(javaVersion); + DownloadUtils.downloadFileMonitored( + getJreSource(javaVersion, arch), + outputFile, + null, + monitor + ); + String jreName = "External-" + javaVersion; + MultiRTUtils.installRuntimeNamed(NATIVE_LIB_DIR, new FileInputStream(outputFile), jreName); + MultiRTUtils.postPrepare(jreName); + outputFile.delete(); + } catch (IOException e) { + throw new RuntimeException("Failed to download Java "+javaVersion+" for "+arch, e); + } + } + private enum InternalRuntime { JRE_17(17, "Internal-17", "components/jre-new"), - JRE_21(21, "Internal-21", "components/jre-21"); + JRE_21(21, "Internal-21", "components/jre-21"), + JRE_25(25, "Internal-25", "components/jre-25"); public final int majorVersion; public final String name; public final String path; @@ -154,4 +218,24 @@ private enum InternalRuntime { } } + public enum ExternalRuntime { + JRE_8(8, "External-8"), + JRE_17(17, "External-17"), + JRE_21(21, "External-21"), + JRE_25(25, "External-25"); + public final int majorVersion; + public final String name; + public final String downloadLink; + public boolean isDownloading = false; + + ExternalRuntime(int majorVersion, String name) { + this.majorVersion = majorVersion; + this.name = name; + this.downloadLink = getJreSource(majorVersion, archAsString(getDeviceArchitecture())); + } + public void downloadRuntime(Context activity){ + tryDownloadRuntime(activity, majorVersion); + } + } + } \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java index c65aa58add..dd7e770ff8 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java @@ -39,7 +39,7 @@ public void onCreate() { // Write to file, since some devices may not able to show error FileUtils.ensureParentDirectory(crashFile); PrintStream crashStream = new PrintStream(crashFile); - crashStream.append("PojavLauncher crash report\n"); + crashStream.append("Copper crash report\n"); crashStream.append(" - Time: ").append(DateFormat.getDateTimeInstance().format(new Date())).append("\n"); crashStream.append(" - Device: ").append(Build.PRODUCT).append(" ").append(Build.MODEL).append("\n"); crashStream.append(" - Android version: ").append(Build.VERSION.RELEASE).append("\n"); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java index ae9d5421e7..e8a95e88ea 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java @@ -8,6 +8,13 @@ import net.kdt.pojavlaunch.value.MinecraftAccount; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + public class PojavProfile { private static final String PROFILE_PREF = "pojav_profile"; private static final String PROFILE_PREF_FILE = "file"; @@ -29,6 +36,27 @@ public static String getCurrentProfileName(Context ctx) { } return name; } + + public static List getAllProfiles(){ + List mcAccountList = new ArrayList<>();; + for (String accountName : getAllProfilesList()){ + if (MinecraftAccount.load(accountName) != null) { + mcAccountList.add(MinecraftAccount.load(accountName)); + } + } + return mcAccountList; + } + + public static List getAllProfilesList(){ + List accountList = new ArrayList<>(); + File accountFolder = new File(Tools.DIR_ACCOUNT_NEW); + if(accountFolder.exists() && accountFolder.list() != null){ + for (String fileName : Objects.requireNonNull(accountFolder.list())) { + accountList.add(fileName.substring(0, fileName.length() - 5)); + } + } + return accountList; + } public static void setCurrentProfile(@NonNull Context ctx, @Nullable Object obj) { SharedPreferences.Editor pref = getPrefs(ctx).edit(); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java index ed09c85320..d79718ccd3 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java @@ -2,10 +2,14 @@ import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.P; +import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid; +import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture; import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; +import static net.kdt.pojavlaunch.PojavProfile.getAllProfiles; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_IGNORE_NOTCH; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_NOTCH_SIZE; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; import android.app.NotificationChannel; @@ -21,6 +25,8 @@ import android.database.Cursor; import android.hardware.Sensor; import android.hardware.SensorManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -32,6 +38,7 @@ import android.util.ArrayMap; import android.util.DisplayMetrics; import android.util.Log; +import android.view.InputDevice; import android.view.View; import android.view.WindowManager; import android.widget.EditText; @@ -58,6 +65,7 @@ import net.kdt.pojavlaunch.multirt.Runtime; import net.kdt.pojavlaunch.plugins.FFmpegPlugin; import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.tasks.AsyncAssetManager; import net.kdt.pojavlaunch.utils.DateUtils; import net.kdt.pojavlaunch.utils.DownloadUtils; import net.kdt.pojavlaunch.utils.FileUtils; @@ -74,12 +82,15 @@ import org.apache.commons.codec.binary.Hex; import org.apache.commons.io.IOUtils; +import org.libsdl.app.SDLControllerManager; import org.lwjgl.glfw.CallbackBridge; import java.io.BufferedInputStream; +import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -87,6 +98,8 @@ import java.io.StringWriter; import java.lang.ref.WeakReference; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.text.ParseException; @@ -101,22 +114,22 @@ public final class Tools { public static final float BYTE_TO_MB = 1024 * 1024; public static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); - public static String APP_NAME = "PojavLauncher"; + public static String APP_NAME = "Copper"; public static final Gson GLOBAL_GSON = new GsonBuilder().setPrettyPrinting().create(); - public static final String URL_HOME = "https://pojavlauncherteam.github.io"; + public static final String URL_HOME = "https://angelauramc.dev/wiki"; public static String NATIVE_LIB_DIR; public static String DIR_DATA; //Initialized later to get context public static File DIR_CACHE; public static String MULTIRT_HOME; public static String LOCAL_RENDERER = null; public static int DEVICE_ARCHITECTURE; - public static final String LAUNCHERPROFILES_RTPREFIX = "pojav://"; + public static final String LAUNCHERPROFILES_RTPREFIX = "amethyst://"; // New since 3.3.1 public static String DIR_ACCOUNT_NEW; - public static String DIR_GAME_HOME = Environment.getExternalStorageDirectory().getAbsolutePath() + "/games/PojavLauncher"; + public static String DIR_GAME_HOME = Environment.getExternalStorageDirectory().getAbsolutePath() + "/games/Amethyst"; public static String DIR_GAME_NEW; // New since 3.0.0 @@ -133,13 +146,16 @@ public final class Tools { public static String CTRLMAP_PATH; public static String CTRLDEF_FILE; private static RenderersList sCompatibleRenderers; + public static int iLwjglVersion = 0; + public static String sLwjglVersion = null; + public static String lwjglNativesDir = null; private static File getPojavStorageRoot(Context ctx) { if(SDK_INT >= 29) { return ctx.getExternalFilesDir(null); }else{ - return new File(Environment.getExternalStorageDirectory(),"games/PojavLauncher"); + return new File(Environment.getExternalStorageDirectory(),"games/Amethyst"); } } @@ -202,21 +218,93 @@ public static void initStorageConstants(Context ctx){ CTRLDEF_FILE = DIR_GAME_HOME + "/controlmap/default.json"; } + @SuppressLint("PrivateApi") + private static String systemPropertiesGet(String systemProperty) throws NoSuchMethodException, ClassNotFoundException, InvocationTargetException, IllegalAccessException { + Class cSystemProperties = Class.forName("android.os.SystemProperties"); + Method get = cSystemProperties.getMethod("get", String.class); + return (String) get.invoke(null, systemProperty); + } + + private static boolean isAdreno740(){ + try { + BufferedReader br = new BufferedReader( + new FileReader("/sys/class/kgsl/kgsl-3d0/gpu_model") + ); + String gpuRenderer = br.readLine(); + return gpuRenderer != null && + gpuRenderer.toLowerCase().contains("adreno") && + gpuRenderer.contains("740"); + } catch (IOException e) { + // If it doesn't exist, we definitely aren't on 740 + return false; + } + } + + /** + * Detects whether or not you are on OneUI and using Adreno 740 + * + * Mesa sets it to 0 by default due to vendor quirks + * + * It is possible that OneUI simply deviates from this commonality, hence why + * + * this is a common fix + * + * @return Whether or not to export FD_DEV_FEATURES=enable_ubwc_flag_hint=1 + */ + public static boolean shouldUseUBWC() { + try { + boolean isSamsung = Build.MANUFACTURER.equalsIgnoreCase("samsung"); + boolean isOneUI = !systemPropertiesGet("ro.build.version.oneui").isBlank(); + return isOneUI && isSamsung && isAdreno740(); + } catch (Exception e) { + return false; + } + } + + + /** + * @return The selected "Custom path" of the current profile + */ + @NonNull + private static File getGameDir() { + return getGameDirPath(LauncherProfiles.getCurrentProfile()); + } + /** - * Optimization mods based on Sodium can mitigate the render distance issue. Check if Sodium - * or its derivative is currently installed to skip the render distance check. + * Searches for mod in mods directory of current selected profile + * Not case-sensitive + * @param filenames Filename(s) of the .jar mod(s) + * @return Whether or not the .jar is found + */ + public static boolean hasMods(String... filenames) { + File gameDir = getGameDir(); + File modsDir = new File(gameDir, "mods"); + File[] modFiles = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); + if (modFiles == null) return false; + for (File file : modFiles) { + for (String filename : filenames) + if (file.getName().toLowerCase().contains(filename.toLowerCase())) return true; + } + return false; + } + + /** + * Search for TouchController mod to automatically enable TouchController mod support. + * * @param gameDir current game directory - * @return whether sodium or a sodium-based mod is installed + * @return whether TouchController is found */ - private static boolean hasSodium(File gameDir) { + public static boolean hasTouchController(File gameDir) { File modsDir = new File(gameDir, "mods"); File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); - if(mods == null) return false; - for(File file : mods) { - String name = file.getName(); - if(name.contains("sodium") || - name.contains("embeddium") || - name.contains("rubidium")) return true; + if (mods == null) { + return false; + } + for (File file : mods) { + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.contains("touchcontroller")) { + return true; + } } return false; } @@ -237,10 +325,12 @@ private static boolean affectedByRenderDistanceIssue() { return info.isAdreno() && info.glesMajorVersion >= 3; } - private static boolean checkRenderDistance(File gamedir) { + private static String[] sodiumMods = {"sodium", "embeddium", "rubidium", "xenon"}; + + private static boolean affectedByLTWRenderDistanceIssue() { if(!"opengles3_ltw".equals(Tools.LOCAL_RENDERER)) return false; if(!affectedByRenderDistanceIssue()) return false; - if(hasSodium(gamedir)) return false; + if(hasMods(sodiumMods)) return false; int renderDistance; try { @@ -283,7 +373,9 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc } LauncherProfiles.load(); File gamedir = Tools.getGameDirPath(minecraftProfile); - if(checkRenderDistance(gamedir)) { + startControllableMitigation(activity, gamedir); + startOldLegacy4JMitigation(activity, gamedir); + if(affectedByLTWRenderDistanceIssue()) { LifecycleAwareAlertDialog.DialogCreator dialogCreator = ((alertDialog, dialogBuilder) -> dialogBuilder.setMessage(activity.getString(R.string.ltw_render_distance_warning_msg)) .setPositiveButton(android.R.string.ok, (d, w)->{})); @@ -312,11 +404,11 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc OldVersionsUtils.selectOpenGlVersion(versionInfo); - String launchClassPath = generateLaunchClassPath(versionInfo, versionId); + String launchClasspath = generateLaunchClasspath(versionInfo, versionId); List javaArgList = new ArrayList<>(); - getCacioJavaArgs(javaArgList, runtime.javaVersion == 8); + getCacioJavaArgs(javaArgList, runtime.javaVersion == 8, activity); if (versionInfo.logging != null) { String configFile = Tools.DIR_DATA + "/security/" + versionInfo.logging.client.file.id.replace("client", "log4j-rce-patch"); @@ -327,16 +419,69 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc } File versionSpecificNativesDir = new File(Tools.DIR_CACHE, "natives/"+versionId); + StringBuilder javaLibraryPath = new StringBuilder(); + + // Add which lwjgl natives to use into classpath + javaLibraryPath.append(lwjglNativesDir).append(":"); + + // Add JNA native if needed + javaLibraryPath.append(Tools.NATIVE_LIB_DIR).append(":"); if(versionSpecificNativesDir.exists()) { String dirPath = versionSpecificNativesDir.getAbsolutePath(); - javaArgList.add("-Djava.library.path="+dirPath+":"+Tools.NATIVE_LIB_DIR); + javaLibraryPath.append(dirPath).append(":"); javaArgList.add("-Djna.boot.library.path="+dirPath); } + javaArgList.add("-Djava.library.path="+javaLibraryPath); javaArgList.addAll(Arrays.asList(getMinecraftJVMArgs(versionId, gamedir))); - javaArgList.add("-cp"); - javaArgList.add(launchClassPath + ":" + getLWJGL3ClassPath()); + javaArgList.add("-cp"); javaArgList.add(launchClasspath); + + // Some modloaders (babric) don't fully respect java.libary.path and only use the native lib dir + // This arg makes them use it. LWJGL prioritizes this path during native loading as well. + javaArgList.add("-Dorg.lwjgl.librarypath="+lwjglNativesDir); + + // Forge 1.6.4 crash mitigation + // https://github.com/MinecraftForge/FML/blob/f1b3381e61fac1a0ae90f521223c6bc613eb4888/common/cpw/mods/fml/common/asm/FMLSanityChecker.java#L192-L208 + // It for some reason fails certification and crashes because it thinks Minecraft is corrupted. + // This also has no loading screen as a result. + javaArgList.add("-Dfml.ignoreInvalidMinecraftCertificates=true"); + + // imgui-java set library name to use. This because Axiom uses a fork with different library naming + // logic that doesn't seem to appear in the main repository. I'm not gonna work with that. + javaArgList.add("-Dimgui.library.name=imgui-java"); + // We use an abomination to support all DH versions with a single library. + javaArgList.add("-DZstdNativePath="+Tools.NATIVE_LIB_DIR+"/libzstd-jni-1.5.7-6-dhcompat.so"); + // We only ever reach this point when user has already used the force run switch + boolean hasSodiumMod = false; + for (String modName : sodiumMods) { + if (hasMods(sodiumMods)) { + hasSodiumMod = true; + File mixinPropertiesConfigFile = new File(getGameDir(), "config/" + modName + "-mixins.properties"); + // Write mixin configs to somewhat help stability. We don't want more people complaining. + String[] propertiesToAdd = { + "mixin.features.buffer_builder.intrinsics=false", + "mixin.features.chunk_rendering=false" + }; + List mixinPropertiesConfigStrings = null; + try { + mixinPropertiesConfigStrings = org.apache.commons.io.FileUtils.readLines(mixinPropertiesConfigFile, "UTF-8"); + } catch (IOException ignored) {} + if (mixinPropertiesConfigStrings == null) { + mixinPropertiesConfigStrings = new ArrayList<>(); + } + for (String newLine : propertiesToAdd) { + if (!mixinPropertiesConfigStrings.contains(newLine)) { + mixinPropertiesConfigStrings.add(newLine); + } + } + try { + org.apache.commons.io.FileUtils.writeLines(mixinPropertiesConfigFile, mixinPropertiesConfigStrings); + } catch (IOException ignored) {} // If we can't write it, we tried our best. + } + } + // We use a janky lwjgl setup. We don't want more people complaining it crashes. + if (hasSodiumMod) javaArgList.add("-Dsodium.checks.issue2561=false"); javaArgList.add(versionInfo.mainClass); javaArgList.addAll(Arrays.asList(launchArgs)); // ctx.appendlnToLog("full args: "+javaArgList.toString()); @@ -347,6 +492,109 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc // If we returned, this means that the JVM exit dialog has been shown and we don't need to be active anymore. // We never return otherwise. The process will be killed anyway, and thus we will become inactive } + private static Logger.eventLogListener controllableMitigationLogListener; + /* + * This is does not work when debugging. This is not reliable. + * This is a monstrosity that races the mod, trying to ensure that when the folder is checked + * after extraction but before dlopen, it is empty, so it loads the bundled SDL2 we have instead + */ + private static void startControllableMitigation(Activity activity ,File gamedir) { + String TAG = "ControllableMitigation"; + File deleted = new File(gamedir + "/controllable_natives/SDL"); + boolean hasControllable = false; + File modsDir = new File(gamedir, "mods"); + File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); + if (mods != null) { + for (File file : mods) { + String name = file.getName(); + if (name.contains("controllable")) { + hasControllable = true; + break; + } + } + } + if (hasControllable) { + Tools.runOnUiThread(() -> { + Tools.dialog(activity, activity.getString(R.string.global_warning), activity.getString(R.string.controllableFound)); + }); + Thread mitigationThread = new Thread(() -> { + // This is total garbage but it seems to be the best jank for the job + Log.i(TAG, "Controllable detected! Starting mitigation thread"); + try {org.apache.commons.io.FileUtils.deleteDirectory(deleted);} catch (IOException ignored) {} + while (!Thread.currentThread().isInterrupted()) { + // Looks for controllable_natives/SDL//libSDL2.so and + // deletes it. We can assume array index 0 because this dir gets fully deleted + // before the loop is started. + if (deleted.isDirectory()) { + if (deleted.listFiles().length > 0) { + if (deleted.listFiles()[0].listFiles().length > 0) { + if (deleted.listFiles()[0].listFiles()[0].exists()) { + deleted.listFiles()[0].listFiles()[0].delete(); + break; + } + } + } + } + } + // We can end here because SdlNativeLibraryLoader only extracts libSDL2.so once + // If NativeLibrary can't find it in the folder to load() it uses java.library.path + Log.i(TAG, "Success! Ending Controllable crash mitigation.."); + }); + mitigationThread.start(); + controllableMitigationLogListener = loggedLine -> { + // Hard off switch if it somehow didn't delete anything, just in case. + if (loggedLine.contains("Sound engine started") && mitigationThread.isAlive()) { + Log.i(TAG, "Nothing happened. Ending Controllable crash mitigation.."); + Logger.removeLogListener(controllableMitigationLogListener); + mitigationThread.interrupt(); + } + }; + Logger.addLogListener(controllableMitigationLogListener); + } + } + + private static Logger.eventLogListener oldL4JMitigationLogListener; + /// TODO: Remove when the time is right + /** + * Legacy4J for a long time had broken SDL detection for android, we need to check and + * accommodate this for now. At least until the broken logic are on versions considered + * obsolete. + *

+ * This is of course, very jank, it does not work for anything below 1.7.5 but why is anyone + * on that version anyway? Legacy4J has LTS for like all the versions. + */ + private static void startOldLegacy4JMitigation(Activity activity, File gamedir) { + boolean hasLegacy4J = false; + File modsDir = new File(gamedir, "mods"); + File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); + if(mods != null) { + for (File file : mods) { + String name = file.getName(); + if (name.contains("Legacy4J")) { + hasLegacy4J = true; + break; + } + } + } + if (hasLegacy4J) { + String TAG = "OldLegacy4JMitigation"; + Log.i(TAG, "Legacy4J detected!"); + oldL4JMitigationLogListener = loggedLine -> { + if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU && loggedLine.contains("literal{SDL3 (isXander's libsdl4j)} isn't supported in this system. GLFW will be used instead.")) { + Log.i(TAG, "Old version of Legacy4J detected! Force enabling SDL"); + Tools.SDL.initializeControllerSubsystems(); + Tools.runOnUiThread(() -> { + Tools.dialog(activity, activity.getString(R.string.global_warning), activity.getString(R.string.oldL4JFound)); + }); + Logger.removeLogListener(oldL4JMitigationLogListener); + } else if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU && loggedLine.contains("Added SDL Controller Mappings")) { + Log.i(TAG, "Fixed version of Legacy4J detected! Have fun!"); + Logger.removeLogListener(oldL4JMitigationLogListener); + } + }; + Logger.addLogListener(oldL4JMitigationLogListener); + } + } public static File getGameDirPath(@NonNull MinecraftProfile minecraftProfile){ if(minecraftProfile.gameDir != null){ @@ -387,7 +635,7 @@ public static void disableSplash(File dir) { } } - public static void getCacioJavaArgs(List javaArgList, boolean isJava8) { + public static void getCacioJavaArgs(List javaArgList, boolean isJava8, Activity activity) { // Caciocavallo config AWT-enabled version javaArgList.add("-Djava.awt.headless=false"); javaArgList.add("-Dcacio.managed.screensize=" + AWTCanvasView.AWT_CANVAS_WIDTH + "x" + AWTCanvasView.AWT_CANVAS_HEIGHT); @@ -398,10 +646,20 @@ public static void getCacioJavaArgs(List javaArgList, boolean isJava8) { javaArgList.add("-Dawt.toolkit=net.java.openjdk.cacio.ctc.CTCToolkit"); javaArgList.add("-Djava.awt.graphicsenv=net.java.openjdk.cacio.ctc.CTCGraphicsEnvironment"); } else { + File caciocavavallo17Dir = new File(Tools.DIR_GAME_HOME, "caciocavallo17"); + File[] caciocavallo17Jars = caciocavavallo17Dir.listFiles((f, s) ->s.contains("cacio-tta")); + if(caciocavallo17Jars == null || caciocavallo17Jars.length < 1) { + // We wanna avoid the launch being interrupted so we extract again if it isn't found + AsyncAssetManager.unpackComponents(activity); + caciocavallo17Jars = caciocavavallo17Dir.listFiles((f, s) ->s.contains("cacio-tta")); + if(caciocavallo17Jars == null || caciocavallo17Jars.length < 1) + throw new RuntimeException("Failed to extract required assets!"); + } + javaArgList.add("-javaagent:"+caciocavallo17Jars[0].getAbsolutePath()); javaArgList.add("-Dawt.toolkit=com.github.caciocavallosilano.cacio.ctc.CTCToolkit"); javaArgList.add("-Djava.awt.graphicsenv=com.github.caciocavallosilano.cacio.ctc.CTCGraphicsEnvironment"); - javaArgList.add("-Djava.system.class.loader=com.github.caciocavallosilano.cacio.ctc.CTCPreloadClassLoader"); - + // This approach breaks kilt so we use an agent instead +// javaArgList.add("-Djava.system.class.loader=com.github.caciocavallosilano.cacio.ctc.CTCPreloadClassLoader"); javaArgList.add("--add-exports=java.desktop/java.awt=ALL-UNNAMED"); javaArgList.add("--add-exports=java.desktop/java.awt.peer=ALL-UNNAMED"); javaArgList.add("--add-exports=java.desktop/sun.awt.image=ALL-UNNAMED"); @@ -438,24 +696,47 @@ public static void getCacioJavaArgs(List javaArgList, boolean isJava8) { public static String[] getMinecraftJVMArgs(String versionName, File gameDir) { JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionName, true); - // Parse Forge 1.17+ additional JVM Arguments - if (versionInfo.inheritsFrom == null || versionInfo.arguments == null || versionInfo.arguments.jvm == null) { + if (versionInfo.arguments == null || versionInfo.arguments.jvm == null) return new String[0]; - } Map varArgMap = new ArrayMap<>(); varArgMap.put("classpath_separator", ":"); varArgMap.put("library_directory", DIR_HOME_LIBRARY); varArgMap.put("version_name", versionInfo.id); - varArgMap.put("natives_directory", Tools.NATIVE_LIB_DIR); + varArgMap.put("natives_directory", Tools.DIR_CACHE.getAbsolutePath()); List minecraftArgs = new ArrayList<>(); - if (versionInfo.arguments != null) { - for (Object arg : versionInfo.arguments.jvm) { - if (arg instanceof String) { - minecraftArgs.add((String) arg); - } //TODO: implement (?maybe?) - } + for (Object arg : versionInfo.arguments.jvm) { + if (arg instanceof String) { + // These are defined later on + if (((String) arg).contains("java.library.path")) { + continue; + } + if (arg.equals("-cp")) { + continue; + } + if (arg.equals("${classpath}")){ + continue; + } + // Should fix Forge 1.17.1-37.0.12 and older from crashing + // Fixed in forge on https://github.com/MinecraftForge/MinecraftForge/pull/7919 + // Released as Forge 1.17.1-37.0.13 in https://maven.minecraftforge.net/net/minecraftforge/forge/1.17.1-37.0.13/forge-1.17.1-37.0.13-changelog.txt + // yes this duplicates it, it's fine. + // FIXME: Workaround old bootstraplauncher <0.1.17 buggy behaviour. See FCL workaround + // https://github.com/FCL-Team/FoldCraftLauncher/blob/00e96bcf8ddc8a550e9aba6091a73d5bee973b54/FCLCore/src/main/java/com/tungsten/fclcore/download/MaintainTask.java#L198-L200 + if (((String) arg).startsWith("-DignoreList=")){ + minecraftArgs.add(arg+",${version_name}.jar"); + continue; + } + + // TODO: Implement adding launcher brand and version + if (((String) arg).contains("minecraft.launcher.brand") || + ((String) arg).contains("minecraft.launcher.version")) { + continue; + } + + minecraftArgs.add((String) arg); + } //TODO: implement (?maybe?) } return JSONUtils.insertJSONValueList(minecraftArgs.toArray(new String[0]), varArgMap); } @@ -542,55 +823,54 @@ public static String artifactToPath(DependentLibrary library) { library.downloads.artifact.path != null) return library.downloads.artifact.path; String[] libInfos = library.name.split(":"); - return libInfos[0].replaceAll("\\.", "/") + "/" + libInfos[1] + "/" + libInfos[2] + "/" + libInfos[1] + "-" + libInfos[2] + ".jar"; - } - - public static String getClientClasspath(String version) { - return DIR_HOME_VERSION + "/" + version + "/" + version + ".jar"; + return libInfos[0].replaceAll("\\.", "/") + "/" + libInfos[1] + "/" + libInfos[2] + "/" + libInfos[1] + "-" + libInfos[2] + (libInfos.length == 4 ? "-" + libInfos[3] : "") + ".jar"; } - private static String getLWJGL3ClassPath() { - StringBuilder libStr = new StringBuilder(); - File lwjgl3Folder = new File(Tools.DIR_GAME_HOME, "lwjgl3"); - File[] lwjgl3Files = lwjgl3Folder.listFiles(); - if (lwjgl3Files != null) { - for (File file: lwjgl3Files) { - if (file.getName().endsWith(".jar")) { - libStr.append(file.getAbsolutePath()).append(":"); - } - } - } - // Remove the ':' at the end - libStr.setLength(libStr.length() - 1); - return libStr.toString(); - } - - private final static boolean isClientFirst = false; - public static String generateLaunchClassPath(JMinecraftVersionList.Version info, String actualname) { - StringBuilder finalClasspath = new StringBuilder(); //versnDir + "/" + version + "/" + version + ".jar:"; - + private static String getLibClasspath(JMinecraftVersionList.Version info){ + StringBuilder libClasspath = new StringBuilder(); String[] classpath = generateLibClasspath(info); - - if (isClientFirst) { - finalClasspath.append(getClientClasspath(actualname)); - } for (String jarFile : classpath) { - if (!FileUtils.exists(jarFile)) { - Log.d(APP_NAME, "Ignored non-exists file: " + jarFile); - continue; - } - finalClasspath.append((isClientFirst ? ":" : "")).append(jarFile).append(!isClientFirst ? ":" : ""); - } - if (!isClientFirst) { - finalClasspath.append(getClientClasspath(actualname)); + libClasspath.append(jarFile).append(":"); } + // Remove the ':' at the end + libClasspath.setLength(libClasspath.length() - 1); + return libClasspath.toString(); + } - return finalClasspath.toString(); + public static String getClientClasspath(String version) { + return DIR_HOME_VERSION + "/" + version + "/" + version + ".jar"; } + public static String generateLaunchClasspath(JMinecraftVersionList.Version info, String actualname) { + StringBuilder launchClasspath = new StringBuilder(); //versnDir + "/" + version + "/" + version + ".jar:"; + String libClasspath = getLibClasspath(info); // Sets lwjglVersion, janky, but we can't get it any simpler + String internalLwjglVersion = iLwjglVersion >= 341 ? "3.4.1" : "3.3.3"; + File lwjgl3Folder = new File(Tools.DIR_GAME_HOME, "lwjgl3/"+internalLwjglVersion); + String lwjglCore = lwjgl3Folder.getAbsolutePath() + "/lwjgl.jar"; + String lwjglMerged = lwjgl3Folder.getAbsolutePath() + "/lwjgl-"+internalLwjglVersion+"-merged-modules"; + String lwjglxFile = lwjgl3Folder + "/lwjgl-lwjglx.jar"; + launchClasspath.append(lwjglCore).append(":"); + // 2nd in priority in case we need to merge lwjgl.jar again for testing + launchClasspath.append(lwjglMerged).append(":"); + File[] lwjglModules = lwjgl3Folder.listFiles(pathname -> + pathname.getName().endsWith(".jar") && + // Exclude our two special jars which goes first and last + !pathname.getName().equals("lwjgl.jar") && + !pathname.getName().endsWith("lwjglx.jar")); + if (lwjglModules != null) { + for (File lwjglModule : lwjglModules) + launchClasspath.append(lwjglModule.getAbsolutePath()).append(":"); + } else Log.e("generateLaunchClasspath", "lwjgl modules are missing from components!"); + + launchClasspath.append(libClasspath).append(":"); + launchClasspath.append(getClientClasspath(actualname)); + // Anything LWJGL2 gets LWJGLX + if (iLwjglVersion <= 299) launchClasspath.append(":").append(lwjglxFile); + return launchClasspath.toString(); + } public static DisplayMetrics getDisplayMetrics(Activity activity) { DisplayMetrics displayMetrics = new DisplayMetrics(); @@ -795,6 +1075,9 @@ public static void showErrorRemote(String rolledMessage, Throwable e) { public static void dialogOnUiThread(final Activity activity, final CharSequence title, final CharSequence message) { activity.runOnUiThread(()->dialog(activity, title, message)); } + public static void dialogOnUiThread(final Activity activity, final int title, final int message) { + dialogOnUiThread(activity, activity.getString(title), activity.getString(message)); + } public static void dialog(final Context context, final CharSequence title, final CharSequence message) { new AlertDialog.Builder(context) @@ -868,9 +1151,46 @@ private static void createLibraryInfo(DependentLibrary library) { public static String[] generateLibClasspath(JMinecraftVersionList.Version info) { List libDir = new ArrayList<>(); for (DependentLibrary libItem: info.libraries) { + // Look for LWJGL version + int libItemVersionStringOffset = 0; + if(libItem.name.startsWith("org.lwjgl.lwjgl:lwjgl:")) { + libItemVersionStringOffset = "org.lwjgl.lwjgl:lwjgl:".length(); + } else if (libItem.name.startsWith("org.lwjgl:lwjgl:")) { + libItemVersionStringOffset = "org.lwjgl:lwjgl:".length(); + } + // If we already have a valid LWJGL version, skip this block + if (libItemVersionStringOffset != 0 && (iLwjglVersion < 200 || iLwjglVersion > 999)) { + while (libItemVersionStringOffset < libItem.name.length()) { + char c = libItem.name.charAt(libItemVersionStringOffset); + if (c >= '0' && c <= '9') { + iLwjglVersion = iLwjglVersion * 10 + (c - '0'); + } else if (c == '.') { + // skip dots + } else { + break; // If the dots and numbers stop then its time to finish + } + libItemVersionStringOffset++; + } + } + + String libPath = Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(libItem); + if (!FileUtils.exists(libPath)) { + Log.d(APP_NAME, "Ignored non-exists file: " + libPath); + continue; + } if(!checkRules(libItem.rules)) continue; - libDir.add(Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(libItem)); + libDir.add(libPath); + // Mitigation: Babric doesn't use asm-all for some reason so it does a classpath conflict + if (libItem.name.startsWith("org.ow2.asm:asm") && !libItem.name.startsWith("org.ow2.asm:asm-all:")){ + libDir.remove(Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(new DependentLibrary(){{ + name = "org.ow2.asm:asm-all:5.0.4"; + }} )); + } } + // Scary message, but we aren't getting LWJGL 1.9.9 or 3.10.100 any time soon + if (iLwjglVersion < 200 || iLwjglVersion > 999) throw new RuntimeException("Unable to determine LWJGL version, JSON may be corrupt."); + sLwjglVersion = iLwjglVersion >= 341 ? "3.4.1" : "3.3.3"; + lwjglNativesDir = String.format("%s/lwjgl-%s-natives/%s", Tools.DIR_DATA, sLwjglVersion, archAsStringAndroid(getDeviceArchitecture())); return libDir.toArray(new String[0]); } @@ -896,7 +1216,7 @@ public static JMinecraftVersionList.Version getVersionInfo(String versionName, b insertSafety(inheritsVer, customVer, "assetIndex", "assets", "id", "mainClass", "minecraftArguments", - "releaseTime", "time", "type" + "releaseTime", "time", "type", "inheritsFrom" ); // Go through the libraries, remove the ones overridden by the custom version @@ -1364,8 +1684,8 @@ public static RenderersList getCompatibleRenderers(Context context) { String[] defaultRenderers = resources.getStringArray(R.array.renderer_values); String[] defaultRendererNames = resources.getStringArray(R.array.renderer); boolean deviceHasVulkan = checkVulkanSupport(context.getPackageManager()); - // Currently, only 32-bit x86 does not have the Zink binary - boolean deviceHasZinkBinary = !(Architecture.is32BitsDevice() && Architecture.isx86Device()); + // Zink is now also optional because it sucks + boolean deviceHasOSMesaZinkBinary = new File(Tools.NATIVE_LIB_DIR, "libOSMesa.so").exists(); boolean deviceHasOpenGLES3 = JREUtils.getDetectedVersion() >= 3; // LTW is an optional proprietary dependency boolean appHasLtw = new File(Tools.NATIVE_LIB_DIR, "libltw.so").exists(); @@ -1374,7 +1694,7 @@ public static RenderersList getCompatibleRenderers(Context context) { for(int i = 0; i < defaultRenderers.length; i++) { String rendererId = defaultRenderers[i]; if(rendererId.contains("vulkan") && !deviceHasVulkan) continue; - if(rendererId.contains("zink") && !deviceHasZinkBinary) continue; + if(rendererId.contains("vulkan_zink") && !deviceHasOSMesaZinkBinary) continue; if(rendererId.contains("ltw") && (!deviceHasOpenGLES3 || !appHasLtw)) continue; rendererIds.add(rendererId); rendererNames.add(defaultRendererNames[i]); @@ -1413,4 +1733,141 @@ public static void dialogForceClose(Context ctx) { } }).show(); } + + public static void switchDemo(boolean isDemo){ + DIR_GAME_NEW = DIR_GAME_HOME + "/.minecraft"; + DIR_HOME_VERSION = DIR_GAME_NEW + "/versions"; + DIR_HOME_LIBRARY = DIR_GAME_NEW + "/libraries"; + ASSETS_PATH = DIR_GAME_NEW + "/assets"; + OBSOLETE_RESOURCES_PATH = DIR_GAME_NEW + "/resources"; + } + + private static NetworkInfo getActiveNetworkInfo(Context ctx) { + ConnectivityManager connMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = connMgr.getActiveNetworkInfo(); + return networkInfo; // This can return null when there is no wifi or data connected + } + + public static boolean isOnline(Context ctx) { + NetworkInfo info = getActiveNetworkInfo(ctx); + if(info == null) return false; + return (info.isConnected()); + } + + public static boolean isDemoProfile(Context ctx){ + return false; + } + + public static boolean isLocalProfile(Context ctx){ + MinecraftAccount currentProfile = PojavProfile.getCurrentProfileContent(ctx, null); + return currentProfile == null || currentProfile.isLocal(); + } + public static boolean hasOnlineProfile(){ + return true; + } + + public static void hasNoOnlineProfileDialog(Activity activity, @Nullable Runnable run, @Nullable String customTitle, @Nullable String customMessage){ + if (hasOnlineProfile() && !Tools.isDemoProfile(activity)){ + if (run != null) { // Demo profile handling should be using customTitle and customMessage + run.run(); + } + } else { // If there is no online profile, show a dialog + customTitle = customTitle == null ? activity.getString(R.string.no_minecraft_account_found) : customTitle; + customMessage = customMessage == null ? activity.getString(R.string.feature_requires_java_account) : customMessage; + dialogOnUiThread(activity, customTitle, customMessage); + } + } + + // Some boilerplate to reduce boilerplate elsewhere + public static void hasNoOnlineProfileDialog(Activity activity){ + hasNoOnlineProfileDialog(activity, null, null, null); + } + public static void hasNoOnlineProfileDialog(Activity activity, Runnable run){ + hasNoOnlineProfileDialog(activity, run, null, null); + } + public static void hasNoOnlineProfileDialog(Activity activity, String customTitle, String customMessage){ + hasNoOnlineProfileDialog(activity, null, customTitle, customMessage); + } + + public static String getSelectedVanillaMcVer(){ + String selectedProfile = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, ""); + MinecraftProfile selected = LauncherProfiles.mainProfileJson.profiles.get(selectedProfile); + if (selected == null) { // This should NEVER happen. + throw new RuntimeException("No profile selected, how did you reach this? Go ask in the discord or github"); + } + String currentMCVersion = selected.lastVersionId; + String vanillaVersion = currentMCVersion; + File providedJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + currentMCVersion + "/" + currentMCVersion + ".json"); + JMinecraftVersionList.Version providedJsonVersion = null; + try { + providedJsonVersion = Tools.GLOBAL_GSON.fromJson(Tools.read(providedJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + try { + vanillaVersion = providedJsonVersion.inheritsFrom != null ? providedJsonVersion.inheritsFrom : vanillaVersion; + } catch (NullPointerException e) { + throw new RuntimeException(e); + } + return vanillaVersion; + } + + public static Integer mcVersiontoInt(String mcVersion){ + String[] sVersionArray = mcVersion.split("\\."); + String[] iVersionArray = new String[3]; + // Make sure this is actually a version string + for (int i = 0; i < iVersionArray.length; i++) { + try { + // Ensure there's padding + sVersionArray[i] = String.format("%3s", sVersionArray[i]).replace(' ', '0'); + // Grab only the last 3, MCJE 999.999.999 isnt coming soon anyway + sVersionArray[i] = sVersionArray[i].substring(sVersionArray[i].length() - 3); + } catch (ArrayIndexOutOfBoundsException ignored){ + // If we don't get 3 a third array, pad with 0s because it's probably 1.21 or something + iVersionArray[i] = "000"; + continue; + } + try { + // Verify its a real deal, legit number + Integer.parseInt(sVersionArray[i]); + iVersionArray[i] = sVersionArray[i]; + } catch (NumberFormatException e) { + throw new RuntimeException("Tools(mcVersiontoInt): Invalid version string"); + } + } + return Integer.parseInt(iVersionArray[0] + iVersionArray[1] + iVersionArray[2]); + } + + public static boolean isPointerDeviceConnected() { + int[] deviceIds = InputDevice.getDeviceIds(); + for (int id : deviceIds) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) continue; + int sources = device.getSources(); + if ((sources & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE + || (sources & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD + || (sources & InputDevice.SOURCE_TRACKBALL) == InputDevice.SOURCE_TRACKBALL) { + return true; + } + } + return false; + } + + public static Object runMethodbyReflection(String className, String methodName) throws ReflectiveOperationException{ + Class clazz = Class.forName(className); + Method method = clazz.getDeclaredMethod(methodName); + method.setAccessible(true); + Object motionListener = method.invoke(null); + assert motionListener != null; + return motionListener; + } + + static class SDL { + /** + * Initializes gamepad, joystick, and event subsystems. + * This triggers {@link SDLControllerManager#pollInputDevices()} and subsequently disables + * the emulated gamepad implementation. + */ + public static native void initializeControllerSubsystems(); + } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java new file mode 100644 index 0000000000..ee3431b806 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java @@ -0,0 +1,48 @@ +package net.kdt.pojavlaunch.authenticator; + +import com.google.gson.annotations.SerializedName; + +import net.kdt.pojavlaunch.authenticator.impl.ElyByBackgroundLogin; +import net.kdt.pojavlaunch.authenticator.impl.MicrosoftBackgroundLogin; + +import git.artdeell.mojo.R; + +public enum AuthType { + @SerializedName("microsoft") + MICROSOFT( + MicrosoftBackgroundLogin.CREATOR, + R.drawable.ic_auth_ms, + null, + "https://mineskin.eu/skin/%s" // Switched from mc-heads.net cause blocked in Russia + ), + @SerializedName("elyby") + ELY_BY( + ElyByBackgroundLogin.CREATOR, + R.drawable.ic_auth_elyby, + "ely.by", + "http://skinsystem.ely.by/skins/%s.png" + ), + @SerializedName("local") + LOCAL(null, 0, null, null); + + private final BackgroundLogin.Creator mCreator; + public final int iconResource; + public final String injectorUrl; + public final String skinUrl; + + AuthType(BackgroundLogin.Creator creator, int iconResource, String injectorUrl, String skinUrl) { + this.mCreator = creator; + this.iconResource = iconResource; + this.injectorUrl = injectorUrl; + this.skinUrl = skinUrl; + } + + public boolean requiresLogin() { + return mCreator != null; + } + + public BackgroundLogin createAuth() { + if(mCreator == null) throw new RuntimeException("This account does not support login"); + return mCreator.create(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java new file mode 100644 index 0000000000..e90781ffce --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java @@ -0,0 +1,14 @@ +package net.kdt.pojavlaunch.authenticator; + +import androidx.annotation.NonNull; + +import net.kdt.pojavlaunch.authenticator.listener.LoginListener; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; + +public interface BackgroundLogin { + void createAccount(@NonNull LoginListener loginListener, String code); + void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account); + interface Creator { + BackgroundLogin create(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java new file mode 100644 index 0000000000..8e5d7ff948 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java @@ -0,0 +1,119 @@ +package net.kdt.pojavlaunch.authenticator.accounts; + +import android.util.Log; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.FileUtils; +import net.kdt.pojavlaunch.utils.JSONUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class Accounts { + private static final String PROFILE_PREF_FILE = "selected_account_file"; + + public final List accounts; + public final int selectionIndex; + + private Accounts(List accounts, int selectionIndex) { + this.accounts = accounts; + this.selectionIndex = selectionIndex; + } + + public static Accounts load() throws IOException { + File accountsDir = new File(Tools.DIR_ACCOUNT_NEW); + synchronized (Accounts.class) { + FileUtils.ensureDirectory(accountsDir); + } + File[] accountFiles = accountsDir.listFiles(); + if(accountFiles == null) throw new IOException("Failed to create account directory"); + String selectedAccount = getSelectedAccount(); + ArrayList accounts = new ArrayList<>(accountFiles.length); + int selectedAccountIdx = 0; + for(File accFile : accountFiles) { + MinecraftAccount account = loadAccount(accFile); + if(account == null) continue; + accounts.add(account); + if(accFile.getName().equals(selectedAccount)) { + selectedAccountIdx = accounts.size() - 1; + } + } + accounts.trimToSize(); + return new Accounts(Collections.unmodifiableList(accounts), selectedAccountIdx); + } + + private static MinecraftAccount loadAccount(File source) { + MinecraftAccount acc; + try { + acc = JSONUtils.readFromFile(source, MinecraftAccount.class); + }catch (Exception e) { + Log.w("Accounts", "Failed to load account", e); + return null; + } + if(acc == null) return null; + acc.mSaveLocation = source; + + if (acc.accessToken == null) { + acc.accessToken = "0"; + } + if (acc.profileId == null) { + acc.profileId = "00000000-0000-0000-0000-000000000000"; + } + if (acc.username == null) { + acc.username = "0"; + } + if (acc.refreshToken == null) { + acc.refreshToken = "0"; + } + if(acc.authType == null) { + acc.authType = acc.isMicrosoft ? AuthType.MICROSOFT : AuthType.LOCAL; + } + return acc; + } + + private static String getSelectedAccount() { + return LauncherPreferences.DEFAULT_PREF.getString(PROFILE_PREF_FILE, ""); + } + + public static MinecraftAccount getCurrent() { + String selectedAccount = getSelectedAccount(); + return loadAccount(new File(Tools.DIR_ACCOUNT_NEW, selectedAccount)); + } + + private static File pickAccountPath() { + File profilePath; + do { + String profileName = UUID.randomUUID().toString(); + profilePath = new File(Tools.DIR_ACCOUNT_NEW, profileName); + } while(profilePath.exists()); + return profilePath; + } + + public static MinecraftAccount create(Setter setter) throws IOException { + MinecraftAccount minecraftAccount = new MinecraftAccount(); + setter.writeAccount(minecraftAccount); + minecraftAccount.mSaveLocation = pickAccountPath(); + minecraftAccount.save(); + return minecraftAccount; + } + + public static void setCurrent(MinecraftAccount minecraftAccount) { + LauncherPreferences.DEFAULT_PREF + .edit().putString(PROFILE_PREF_FILE, minecraftAccount.mSaveLocation.getName()) + .apply(); + } + + public static void delete(MinecraftAccount minecraftAccount) { + boolean ignored = minecraftAccount.mSaveLocation.delete(); + } + + public interface Setter { + void writeAccount(MinecraftAccount minecraftAccount) throws IOException; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java new file mode 100644 index 0000000000..351cbc7539 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java @@ -0,0 +1,96 @@ +package net.kdt.pojavlaunch.authenticator.accounts; + + +import android.graphics.BitmapFactory; +import android.util.Log; + +import net.kdt.pojavlaunch.*; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.utils.FileUtils; +import net.kdt.pojavlaunch.utils.JSONUtils; + +import java.io.*; +import java.net.URL; + +import android.graphics.Bitmap; + +import androidx.annotation.Keep; + +import com.google.gson.JsonParseException; + +import org.apache.commons.io.IOUtils; + +@Keep +public class MinecraftAccount { + public transient File mSaveLocation; + public String accessToken = "0"; // access token + public String profileId = "00000000-0000-0000-0000-000000000000"; // profile UUID, for obtaining skin + public String username = "Steve"; + public AuthType authType = AuthType.LOCAL; + public boolean isMicrosoft = false; + public String refreshToken = "0"; + public String xuid; + public long expiresAt; + private transient Bitmap mFaceCache; + + protected MinecraftAccount() {} + + public void updateSkinFace() { + String skinFaceUrlTemplate = authType.skinUrl; + if(skinFaceUrlTemplate == null) return; + String skinFaceUrl = String.format(skinFaceUrlTemplate, username); + try { + Log.i("SkinLoader", "Updating skin face..."); + File skinFile = getSkinFaceFile(); + // Streaming it directly breaks on some devices + byte[] skinBytes = IOUtils.toByteArray(new URL(skinFaceUrl)); + Bitmap skinBitmap = BitmapFactory.decodeByteArray(skinBytes, 0, skinBytes.length); + if(skinBitmap == null) return; + Bitmap skinFace = new SkinHeadRenderer().render(100, skinBitmap); + skinBitmap.recycle(); + if(skinFace == null) return; + try(FileOutputStream fileOutputStream = new FileOutputStream(skinFile)) { + skinFace.compress(Bitmap.CompressFormat.WEBP, 90, fileOutputStream); + } + Log.i("SkinLoader", "Update skin face success"); + } catch (IOException e) { + // Skin refresh limit, no internet connection, etc... + // Simply ignore updating skin face + Log.w("SkinLoader", "Could not update skin face", e); + } + } + + public boolean isLocal(){ + return accessToken.equals("0"); + } + + public void save() throws IOException { + FileUtils.ensureParentDirectory(mSaveLocation); + JSONUtils.writeToFile(mSaveLocation, this); + } + + public MinecraftAccount reload() { + try { + MinecraftAccount minecraftAccount = JSONUtils.readFromFile(mSaveLocation, MinecraftAccount.class); + if(minecraftAccount == null) return null; + minecraftAccount.mSaveLocation = mSaveLocation; + return minecraftAccount; + }catch (IOException | JsonParseException e) { + return null; + } + } + + public Bitmap getSkinFace(){ + if(isLocal()) return null; + File skinFaceFile = getSkinFaceFile(); + if(!skinFaceFile.exists()) return null; + if(mFaceCache == null) { + mFaceCache = BitmapFactory.decodeFile(skinFaceFile.getAbsolutePath()); + } + return mFaceCache; + } + + private File getSkinFaceFile() { + return new File(Tools.DIR_CACHE, "skin-face-" + profileId +"-"+authType.name() + ".webp"); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java new file mode 100644 index 0000000000..82f7c25b5a --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java @@ -0,0 +1,182 @@ +package net.kdt.pojavlaunch.authenticator.accounts; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.util.Log; + +import java.util.HashSet; +import java.util.Set; + +public class SkinHeadRenderer { + private static final Matrix MIRROR_MATRIX = new Matrix(); + static { + MIRROR_MATRIX.setScale(-1, 1); + } + + // Points of an isometric cube + private static final float[][] ISO_POINTS = { + {0.5f, 1.0f }, // 0 Bottom-most point + {0.9330127f, 0.75f}, // 1 Bottom right point + {0.066987306f, 0.75f}, // 2 Bottom left point + {0.5f, 0.0f }, // 3 Topmost point + {0.9330127f, 0.25f}, // 4 Top right point + {0.066987306f, 0.25f}, // 5 Top left point + {0.5f, 0.5f } // 6 Center point + }; + + // Faces of an isometric cube + private static final int FACE_LEFT = 0; + private static final int FACE_RIGHT = 1; + private static final int FACE_TOP = 2; + private static final int FACE_REAR_RIGHT = 3; + private static final int FACE_REAR_LEFT = 4; + private static final int FACE_BOTTOM = 5; + + private int mCoordScale = 1; + private final float[] mMeshBuffer = new float[8]; + private final Set mTempBitmaps = new HashSet<>(); + + private Bitmap getSubregion(Bitmap src, int left, int top, int right, int bottom) { + return getSubregion(src, left, top, right, bottom, false); + } + + private Bitmap internalGetSubregion(Bitmap src, int left, int top, int right, int bottom, boolean mirror) { + Bitmap bitmap = Bitmap.createBitmap(src, left, top, right - left, bottom - top); + if(!mirror) return bitmap; + Bitmap mirroredBitmap = Bitmap.createBitmap( + bitmap, + 0, 0, + bitmap.getWidth(), bitmap.getHeight(), + MIRROR_MATRIX, false + ); + bitmap.recycle(); + return mirroredBitmap; + } + + private Bitmap getSubregion(Bitmap src, int left, int top, int right, int bottom, boolean mirror) { + // Provision for HD skins: scale regular skin coordinate inputs + left *= mCoordScale; + top *= mCoordScale; + right *= mCoordScale; + bottom *= mCoordScale; + + Bitmap subregion = internalGetSubregion(src, left, top, right, bottom, mirror); + mTempBitmaps.add(subregion); + + return subregion; + } + + /** + * Write one face of an isometric cube as mesh points suitable for drawBitmapMesh() + * @param dst destination array for mesh (should have length of 8) + * @param face the face to write (one of the face constants) + * @param mul the amount by how much each point should be multiplied + * @param off the amount by how much each point should be offset to the right + */ + private void applyFace(float[] dst, int face, float mul, float off) { + switch (face) { + case FACE_LEFT: + dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off; + dst[2] = ISO_POINTS[6][0] * mul + off; dst[3] = ISO_POINTS[6][1] * mul + off; + dst[4] = ISO_POINTS[2][0] * mul + off; dst[5] = ISO_POINTS[2][1] * mul + off; + dst[6] = ISO_POINTS[0][0] * mul + off; dst[7] = ISO_POINTS[0][1] * mul + off; + return; + case FACE_RIGHT: + dst[0] = ISO_POINTS[6][0] * mul + off; dst[1] = ISO_POINTS[6][1] * mul + off; + dst[2] = ISO_POINTS[4][0] * mul + off; dst[3] = ISO_POINTS[4][1] * mul + off; + dst[4] = ISO_POINTS[0][0] * mul + off; dst[5] = ISO_POINTS[0][1] * mul + off; + dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off; + return; + case FACE_TOP: + dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off; + dst[2] = ISO_POINTS[3][0] * mul + off; dst[3] = ISO_POINTS[3][1] * mul + off; + dst[4] = ISO_POINTS[6][0] * mul + off; dst[5] = ISO_POINTS[6][1] * mul + off; + dst[6] = ISO_POINTS[4][0] * mul + off; dst[7] = ISO_POINTS[4][1] * mul + off; + return; + case FACE_REAR_RIGHT: + dst[0] = ISO_POINTS[3][0] * mul + off; dst[1] = ISO_POINTS[3][1] * mul + off; + dst[2] = ISO_POINTS[4][0] * mul + off; dst[3] = ISO_POINTS[4][1] * mul + off; + dst[4] = ISO_POINTS[6][0] * mul + off; dst[5] = ISO_POINTS[6][1] * mul + off; + dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off; + return; + case FACE_REAR_LEFT: + dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off; + dst[2] = ISO_POINTS[3][0] * mul + off; dst[3] = ISO_POINTS[3][1] * mul + off; + dst[4] = ISO_POINTS[2][0] * mul + off; dst[5] = ISO_POINTS[2][1] * mul + off; + dst[6] = ISO_POINTS[6][0] * mul + off; dst[7] = ISO_POINTS[6][1] * mul + off; + return; + case FACE_BOTTOM: + dst[0] = ISO_POINTS[2][0] * mul + off; dst[1] = ISO_POINTS[2][1] * mul + off; + dst[2] = ISO_POINTS[6][0] * mul + off; dst[3] = ISO_POINTS[6][1] * mul + off; + dst[4] = ISO_POINTS[0][0] * mul + off; dst[5] = ISO_POINTS[0][1] * mul + off; + dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off; + } + } + + private void drawMesh(Canvas canvas, Bitmap bitmap, int face, float multiplier, float offset) { + applyFace(mMeshBuffer, face, multiplier, offset); + canvas.drawBitmapMesh(bitmap, + 1, 1, mMeshBuffer, + 0, null, 0, + null + ); + } + + private boolean prepareCoordScale(int sourceDimension) { + if(sourceDimension % 64 != 0) { + Log.e("SkinHeadRenderer", "Invalid skin dimension: "+ sourceDimension); + return false; + } + mCoordScale = sourceDimension / 64; + return true; + } + + public Bitmap render(int side, Bitmap sourceSkin) { + if(!prepareCoordScale(sourceSkin.getWidth())) return null; + + // Bitmap overlay regions + Bitmap overlayTopFace = getSubregion(sourceSkin, 40, 0, 48,8); + Bitmap overlayLeftFace = getSubregion(sourceSkin, 32, 8, 40,16); + Bitmap overlayRightFace = getSubregion(sourceSkin, 40, 8, 48,16); + Bitmap overlayBottomFace = getSubregion(sourceSkin, 48, 0, 56, 8); + Bitmap overlayRearLeftFace = getSubregion(sourceSkin, 56, 8, 64, 16, true); + Bitmap overlayRearRightFace = getSubregion(sourceSkin, 48, 8, 56, 16, true); + + // Bitmap head regions + Bitmap topFace = getSubregion(sourceSkin, 8,0,16,8); + Bitmap leftFace = getSubregion(sourceSkin, 0,8,8,16); + Bitmap rightFace = getSubregion(sourceSkin, 8,8,16,16); + + Bitmap renderTarget = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(renderTarget); + + float multiplier = 1f * side; + + // The head should be slightly smaller than the accessory overlay around it, + // and should appear to be in the middle of the accessory overlay. + float headOffset = multiplier / 16f; + float headMultiplier = multiplier * 14f/16f; + + // Rear side of overlay layer + drawMesh(canvas, overlayRearLeftFace, FACE_REAR_LEFT, multiplier, 0); + drawMesh(canvas, overlayRearRightFace, FACE_REAR_RIGHT, multiplier, 0); + drawMesh(canvas, overlayBottomFace, FACE_BOTTOM, multiplier, 0); + + // Player head + drawMesh(canvas, leftFace, FACE_LEFT, headMultiplier, headOffset); + drawMesh(canvas, rightFace, FACE_RIGHT, headMultiplier, headOffset); + drawMesh(canvas, topFace, FACE_TOP, headMultiplier, headOffset); + + // Front side of the overlay layer + drawMesh(canvas, overlayLeftFace, FACE_LEFT, multiplier, 0); + drawMesh(canvas, overlayRightFace, FACE_RIGHT, multiplier, 0); + drawMesh(canvas, overlayTopFace, FACE_TOP, multiplier, 0); + + // Free all regions + for(Bitmap region : mTempBitmaps) region.recycle(); + mTempBitmaps.clear(); + // Done! + return renderTarget; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java new file mode 100644 index 0000000000..e292ec405e --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java @@ -0,0 +1,68 @@ +package net.kdt.pojavlaunch.authenticator.impl; + +import android.util.Log; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import git.artdeell.mojo.R; + +public class CommonLoginUtils { + + public static OAuthTokenResponse exchangeAuthCode(URL url, String formData) throws IOException { + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("charset", "utf-8"); + conn.setRequestProperty("Content-Length", Integer.toString(formData.getBytes(StandardCharsets.UTF_8).length)); + conn.setRequestMethod("POST"); + conn.setUseCaches(false); + conn.setDoInput(true); + conn.setDoOutput(true); + conn.connect(); + try(OutputStream wr = conn.getOutputStream()) { + wr.write(formData.getBytes(StandardCharsets.UTF_8)); + } + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) { + return Tools.GLOBAL_GSON.fromJson(reader, OAuthTokenResponse.class); + } finally { + conn.disconnect(); + } + }else{ + Log.i("CommonLogin", "Auth fail: "+Tools.read(conn.getErrorStream())); + throw getResponseThrowable(conn); + } + } + + /** + * @param data A series a strings: key1, value1, key2, value2... + * @return the data converted as a form string for a POST request + */ + public static String convertToFormData(String... data) throws UnsupportedEncodingException { + StringBuilder builder = new StringBuilder(); + for(int i=0; i 0) builder.append("&"); + builder.append(URLEncoder.encode(data[i], "UTF-8")) + .append("=") + .append(URLEncoder.encode(data[i+1], "UTF-8")); + } + return builder.toString(); + } + + public static RuntimeException getResponseThrowable(HttpURLConnection conn) throws IOException { + Log.i("MicrosoftLogin", "Error code: " + conn.getResponseCode() + ": " + conn.getResponseMessage()); + if(conn.getResponseCode() == 429) { + return new PresentedException(R.string.microsoft_login_retry_later); + } + return new RuntimeException(conn.getResponseMessage()); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java new file mode 100644 index 0000000000..058e7aafb7 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java @@ -0,0 +1,129 @@ +package net.kdt.pojavlaunch.authenticator.impl; + +import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.authenticator.BackgroundLogin; +import net.kdt.pojavlaunch.authenticator.accounts.Accounts; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; +import net.kdt.pojavlaunch.authenticator.listener.LoginListener; +import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.Callable; + +public class ElyByBackgroundLogin implements BackgroundLogin { + public static final BackgroundLogin.Creator CREATOR = ElyByBackgroundLogin::new; + + private static final String authTokenUrl = "https://account.ely.by/api/oauth2/v1/token"; + private static final String accountInfoUrl = "https://account.ely.by/api/account/v1/info"; + + private OAuthTokenResponse mOAuthData; + private ElyAccountInfo mAccountInfo; + private long mExpiresAt; + + private ElyByBackgroundLogin() {} + + private void acquireAccountDetails( + @NonNull LoginListener loginListener, Callable continuation, + String code, boolean isRefresh + ) { + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0); + sExecutorService.execute(() -> { + loginListener.setMaxLoginProgress(2); + try { + notifyProgress(loginListener, 1); + acquireTokens(isRefresh, code); + notifyProgress(loginListener, 2); + mAccountInfo = acquireAccountData(mOAuthData.accessToken); + continuation.call(); + }catch (Exception e){ + Log.e("MicroAuth", "Exception thrown during authentication", e); + Tools.runOnUiThread(()->loginListener.onLoginError(e)); + } + ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE); + }); + } + + private void fillAccount(MinecraftAccount acc) { + acc.expiresAt = mExpiresAt; + acc.authType = AuthType.ELY_BY; + acc.accessToken = mOAuthData.accessToken; + acc.refreshToken = mOAuthData.refreshToken; + acc.username = mAccountInfo.username; + acc.profileId = mAccountInfo.uuid; + acc.xuid = null; + acc.updateSkinFace(); + } + + @Override + public void createAccount(@NonNull LoginListener loginListener, String code) { + acquireAccountDetails(loginListener, ()->{ + MinecraftAccount account = Accounts.create(this::fillAccount); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + return null; + }, code, false); + } + + @Override + public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) { + acquireAccountDetails(loginListener, ()->{ + fillAccount(account); + account.save(); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + return null; + }, account.refreshToken, true); + } + + private void acquireTokens(boolean isRefresh, String code) throws IOException { + URL url = new URL(authTokenUrl); + Log.i("MicrosoftLogin", "isRefresh=" + isRefresh + ", authCode= "+code); + + String formData = CommonLoginUtils.convertToFormData( + "client_id", "mojolauncher2", + "client_secret", "o14Zb2Zzj0_k6o4kN0t1mIEhoQxeayn8hYi5VSX2q3NXrdQm5T2Q6wqsCfpv1vhu", + "redirect_uri", "internalredirect://complete", + isRefresh ? "refresh_token" : "code", code, + "grant_type", isRefresh ? "refresh_token" : "authorization_code" + ); + mOAuthData = CommonLoginUtils.exchangeAuthCode(url, formData); + mExpiresAt = mOAuthData.expiresIn*1000 + System.currentTimeMillis(); + } + + private ElyAccountInfo acquireAccountData(String accessToken) throws IOException { + URL url = new URL(accountInfoUrl); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setUseCaches(false); + conn.connect(); + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) { + return Tools.GLOBAL_GSON.fromJson(reader, ElyAccountInfo.class); + } finally { + conn.disconnect(); + } + }else{ + throw CommonLoginUtils.getResponseThrowable(conn); + } + } + + private void notifyProgress(LoginListener listener, int step){ + Tools.runOnUiThread(() -> listener.onLoginProgress(step)); + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step*50); + } + + private static class ElyAccountInfo { + public String uuid; + public String username; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java new file mode 100644 index 0000000000..22c083b7a9 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java @@ -0,0 +1,316 @@ +package net.kdt.pojavlaunch.authenticator.impl; + +import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; + +import android.util.ArrayMap; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.kdt.mcgui.ProgressLayout; + +import git.artdeell.mojo.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.authenticator.BackgroundLogin; +import net.kdt.pojavlaunch.authenticator.accounts.Accounts; +import net.kdt.pojavlaunch.authenticator.listener.LoginListener; +import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.Callable; + +/** Allow to perform a background login on a given account */ +public class MicrosoftBackgroundLogin implements BackgroundLogin{ + public static final BackgroundLogin.Creator CREATOR = MicrosoftBackgroundLogin::new; + + private static final String authTokenUrl = "https://login.live.com/oauth20_token.srf"; + private static final String xblAuthUrl = "https://user.auth.xboxlive.com/user/authenticate"; + private static final String xstsAuthUrl = "https://xsts.auth.xboxlive.com/xsts/authorize"; + private static final String mcLoginUrl = "https://api.minecraftservices.com/authentication/login_with_xbox"; + private static final String mcProfileUrl = "https://api.minecraftservices.com/minecraft/profile"; + private static final String mcStoreUrl = "https://api.minecraftservices.com/entitlements/mcstore"; + + private static final Map XSTS_ERRORS; + static { + XSTS_ERRORS = new ArrayMap<>(); + XSTS_ERRORS.put(2148916233L, R.string.xerr_no_account); + XSTS_ERRORS.put(2148916235L, R.string.xerr_not_available); + XSTS_ERRORS.put(2148916236L ,R.string.xerr_adult_verification); + XSTS_ERRORS.put(2148916237L ,R.string.xerr_adult_verification); + XSTS_ERRORS.put(2148916238L ,R.string.xerr_child); + } + + /* Fields used to fill the account */ + public String msRefreshToken; + public String mcName; + public String mcToken; + public String mcUuid; + public String msXsts; + public boolean doesOwnGame; + public long expiresAt; + + private MicrosoftBackgroundLogin() {} + + private void acquireAccountDetails( + @NonNull LoginListener loginListener, Callable continuation, + String code, boolean isRefresh + ) { + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0); + sExecutorService.execute(() -> { + loginListener.setMaxLoginProgress(5); + try { + notifyProgress(loginListener, 1); + String accessToken = acquireAccessToken(isRefresh, code); + notifyProgress(loginListener, 2); + String xboxLiveToken = acquireXBLToken(accessToken); + notifyProgress(loginListener, 3); + String[] xsts = acquireXsts(xboxLiveToken); + notifyProgress(loginListener, 4); + String mcToken = acquireMinecraftToken(xsts[0], xsts[1]); + notifyProgress(loginListener, 5); + fetchOwnedItems(mcToken); + checkMcProfile(mcToken); + msXsts = xsts[0]; + continuation.call(); + }catch (Exception e){ + Log.e("MicroAuth", "Exception thrown during authentication", e); + Tools.runOnUiThread(()->loginListener.onLoginError(e)); + } finally { + ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE); + } + }); + } + + private void fillAccount(MinecraftAccount acc) { + acc.xuid = msXsts; + acc.accessToken = mcToken; + acc.username = mcName; + acc.profileId = mcUuid; + acc.authType = AuthType.MICROSOFT; + acc.refreshToken = msRefreshToken; + acc.expiresAt = expiresAt; + acc.updateSkinFace(); + } + + @Override + public void createAccount(@NonNull LoginListener loginListener, String code) { + acquireAccountDetails(loginListener, ()->{ + MinecraftAccount account = Accounts.create(this::fillAccount); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + return null; + }, code, false); + } + + @Override + public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) { + acquireAccountDetails(loginListener, ()->{ + if(doesOwnGame) fillAccount(account); + account.save(); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + return null; + }, account.refreshToken, true); + } + + private String acquireAccessToken(boolean isRefresh, String code) throws IOException { + URL url = new URL(authTokenUrl); + Log.i("MicrosoftLogin", "isRefresh=" + isRefresh + ", authCode= "+code); + + String formData = CommonLoginUtils.convertToFormData( + "client_id", "00000000402b5328", + isRefresh ? "refresh_token" : "code", code, + "grant_type", isRefresh ? "refresh_token" : "authorization_code", + "redirect_url", "https://login.live.com/oauth20_desktop.srf", + "scope", "service::user.auth.xboxlive.com::MBI_SSL" + ); + + OAuthTokenResponse response = CommonLoginUtils.exchangeAuthCode(url, formData); + msRefreshToken = response.refreshToken; + return response.accessToken; + } + + private String acquireXBLToken(String accessToken) throws IOException, JSONException { + URL url = new URL(xblAuthUrl); + + JSONObject data = new JSONObject(); + JSONObject properties = new JSONObject(); + properties.put("AuthMethod", "RPS"); + properties.put("SiteName", "user.auth.xboxlive.com"); + properties.put("RpsTicket", accessToken); + data.put("Properties",properties); + data.put("RelyingParty", "http://auth.xboxlive.com"); + data.put("TokenType", "JWT"); + + String req = data.toString(); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + setCommonProperties(conn, req); + conn.connect(); + + try(OutputStream wr = conn.getOutputStream()) { + wr.write(req.getBytes(StandardCharsets.UTF_8)); + } + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + JSONObject jo = new JSONObject(Tools.read(conn.getInputStream())); + conn.disconnect(); + Log.i("MicrosoftLogin","Xbl Token = "+jo.getString("Token")); + return jo.getString("Token"); + //acquireXsts(jo.getString("Token")); + }else{ + throw CommonLoginUtils.getResponseThrowable(conn); + } + } + + /** @return [uhs, token]*/ + private @NonNull String[] acquireXsts(String xblToken) throws IOException, JSONException { + URL url = new URL(xstsAuthUrl); + + JSONObject data = new JSONObject(); + JSONObject properties = new JSONObject(); + properties.put("SandboxId", "RETAIL"); + properties.put("UserTokens", new JSONArray(Collections.singleton(xblToken))); + data.put("Properties", properties); + data.put("RelyingParty", "rp://api.minecraftservices.com/"); + data.put("TokenType", "JWT"); + + String req = data.toString(); + Log.i("MicroAuth", req); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + setCommonProperties(conn, req); + Log.i("MicroAuth", conn.getRequestMethod()); + conn.connect(); + + try(OutputStream wr = conn.getOutputStream()) { + wr.write(req.getBytes(StandardCharsets.UTF_8)); + } + + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + JSONObject jo = new JSONObject(Tools.read(conn.getInputStream())); + String uhs = jo.getJSONObject("DisplayClaims").getJSONArray("xui").getJSONObject(0).getString("uhs"); + String token = jo.getString("Token"); + conn.disconnect(); + Log.i("MicrosoftLogin","Xbl Xsts = " + token + "; Uhs = " + uhs); + return new String[]{uhs, token}; + //acquireMinecraftToken(uhs,jo.getString("Token")); + }else if(conn.getResponseCode() == 401) { + String responseContents = Tools.read(conn.getErrorStream()); + JSONObject jo = new JSONObject(responseContents); + long xerr = jo.optLong("XErr", -1); + Integer locale_id = XSTS_ERRORS.get(xerr); + if(locale_id != null) { + throw new PresentedException(new RuntimeException(responseContents), locale_id); + } + throw new PresentedException(new RuntimeException(responseContents), R.string.xerr_unknown, xerr); + }else{ + throw CommonLoginUtils.getResponseThrowable(conn); + } + } + + private String acquireMinecraftToken(String xblUhs, String xblXsts) throws IOException, JSONException { + URL url = new URL(mcLoginUrl); + + JSONObject data = new JSONObject(); + data.put("identityToken", "XBL3.0 x=" + xblUhs + ";" + xblXsts); + + String req = data.toString(); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + setCommonProperties(conn, req); + conn.connect(); + + try(OutputStream wr = conn.getOutputStream()) { + wr.write(req.getBytes(StandardCharsets.UTF_8)); + } + + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + expiresAt = System.currentTimeMillis() + 86400000; + JSONObject jo = new JSONObject(Tools.read(conn.getInputStream())); + conn.disconnect(); + Log.i("MicrosoftLogin","MC token: "+jo.getString("access_token")); + mcToken = jo.getString("access_token"); + //checkMcProfile(jo.getString("access_token")); + return jo.getString("access_token"); + }else{ + throw CommonLoginUtils.getResponseThrowable(conn); + } + } + + private void fetchOwnedItems(String mcAccessToken) throws IOException { + URL url = new URL(mcStoreUrl); + + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestProperty("Authorization", "Bearer " + mcAccessToken); + conn.setUseCaches(false); + conn.connect(); + if(conn.getResponseCode() < 200 || conn.getResponseCode() >= 300) { + throw CommonLoginUtils.getResponseThrowable(conn); + } + // We don't need any data from this request, it just needs to happen in order for + // the MS servers to work properly. The data from this is practically useless + // as it does not indicate whether the user owns the game through Game Pass. + } + + private void checkMcProfile(String mcAccessToken) throws IOException, JSONException { + URL url = new URL(mcProfileUrl); + + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestProperty("Authorization", "Bearer " + mcAccessToken); + conn.setUseCaches(false); + conn.connect(); + + if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + String s= Tools.read(conn.getInputStream()); + conn.disconnect(); + Log.i("MicrosoftLogin","profile:" + s); + JSONObject jsonObject = new JSONObject(s); + String name = (String) jsonObject.get("name"); + String uuid = (String) jsonObject.get("id"); + String uuidDashes = uuid.replaceFirst( + "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", "$1-$2-$3-$4-$5" + ); + doesOwnGame = true; + Log.i("MicrosoftLogin","UserName = " + name); + Log.i("MicrosoftLogin","Uuid Minecraft = " + uuidDashes); + mcName=name; + mcUuid=uuidDashes; + }else{ + Log.i("MicrosoftLogin","It seems that this Microsoft Account does not own the game."); + doesOwnGame = false; + throw new PresentedException(new RuntimeException(conn.getResponseMessage()), R.string.minecraft_not_owned); + //throwResponseError(conn); + } + } + + /** Wrapper to ease notifying the listener */ + private void notifyProgress(LoginListener listener, int step){ + Tools.runOnUiThread(() -> listener.onLoginProgress(step)); + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step*20); + } + + + /** Set common properties for the connection. Given that all requests are POST, interactivity is always enabled */ + private static void setCommonProperties(HttpURLConnection conn, String formData) { + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("charset", "utf-8"); + try { + conn.setRequestProperty("Content-Length", Integer.toString(formData.getBytes(StandardCharsets.UTF_8).length)); + conn.setRequestMethod("POST"); + }catch (ProtocolException e) { + Log.e("MicrosoftAuth", e.toString()); + } + conn.setUseCaches(false); + conn.setDoInput(true); + conn.setDoOutput(true); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java new file mode 100644 index 0000000000..a6bed8ca4b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java @@ -0,0 +1,23 @@ +package net.kdt.pojavlaunch.authenticator.impl; + +import android.content.Context; + +public class PresentedException extends RuntimeException { + final int localizationStringId; + final Object[] extraArgs; + + public PresentedException(int localizationStringId, Object... extraArgs) { + this.localizationStringId = localizationStringId; + this.extraArgs = extraArgs; + } + + public PresentedException(Throwable throwable, int localizationStringId, Object... extraArgs) { + super(throwable); + this.localizationStringId = localizationStringId; + this.extraArgs = extraArgs; + } + + public String toString(Context context) { + return context.getString(localizationStringId, extraArgs); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java new file mode 100644 index 0000000000..aec19d82bf --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java @@ -0,0 +1,10 @@ +package net.kdt.pojavlaunch.authenticator.listener; + +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; + +public interface LoginListener{ + void onLoginDone(MinecraftAccount account); + void onLoginError(Throwable errorMessage); + void onLoginProgress(int step); + void setMaxLoginProgress(int max); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java new file mode 100644 index 0000000000..72ba85f524 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java @@ -0,0 +1,9 @@ +package net.kdt.pojavlaunch.authenticator.model; + +import com.google.gson.annotations.SerializedName; + +public class OAuthTokenResponse { + @SerializedName("access_token") public String accessToken; + @SerializedName("refresh_token") public String refreshToken; + @SerializedName("expires_in") public long expiresIn; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java index 8b011a9040..8bd5e4547e 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java @@ -10,10 +10,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Objects; + // Android's OpenDocument contract is the basicmost crap that doesn't allow // you to specify practically anything. So i made this instead. public class OpenDocumentWithExtension extends ActivityResultContract { private final String mimeType; + private final String[] mimeTypes; /** * Create a new OpenDocumentWithExtension contract. @@ -25,6 +29,37 @@ public OpenDocumentWithExtension(String extension) { String extensionMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if(extensionMimeType == null) extensionMimeType = "*/*"; mimeType = extensionMimeType; + mimeTypes = null; + } + public OpenDocumentWithExtension(String[] extensions) { + ArrayList extensionsMimeType = new ArrayList<>(); + int count = 0; + for (String extension: extensions) { + String extensionMimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (Objects.equals(extension, "mrpack")) { + // Special handling here because depending on whether the ROM has + // `x-modrinth-modpack+zip` in their mimetypes or not because if it does, + // `octet-stream` will no longer match mrpack files. + + // Checking this with MimeTypeMap.hasExtension() and .hasMimeType() always returns + // false so we do both instead. + + // `octet-stream` highlights a lot of unrelated files but it's the best + // we can do. Mimetypes are built into the ROM after all. + // See https://android.googlesource.com/platform/external/mime-support/+/refs/heads/main + // or https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/mime/java-res/android.mime.types + extensionsMimeType.add("application/octet-stream"); + extensionsMimeType.add("application/x-modrinth-modpack+zip"); + count++; + continue; + } + + if(extensionMimetype == null) continue; // If null is passed, it matches all files + extensionsMimeType.add(extensionMimetype); + count++; + } + mimeType = "*/*"; + mimeTypes = extensionsMimeType.toArray(new String[0]); } @NonNull @@ -33,6 +68,7 @@ public Intent createIntent(@NonNull Context context, @NonNull Object input) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(mimeType); + if(mimeTypes != null) intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); return intent; } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java new file mode 100644 index 0000000000..fba82d388b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java @@ -0,0 +1,144 @@ +package net.kdt.pojavlaunch.customcontrols; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; + +import org.apache.commons.io.IOUtils; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +public class LayoutBitmaps { + private static final Random mKeyPicker = new Random(System.nanoTime()); + private final Map mBitmaps; + + private LayoutBitmaps() { + mBitmaps = new HashMap<>(); + } + + private String pickKey() { + String key; + do { + key = Integer.toString(mKeyPicker.nextInt()); + } while (mBitmaps.containsKey(key)); + return key; + } + + public Bitmap getBitmap(String key) { + return mBitmaps.get(key); + } + + public String putBitmap(Bitmap bitmap, String oldKey) { + String newKey = pickKey(); + mBitmaps.remove(oldKey); + if(bitmap != null) mBitmaps.put(newKey, bitmap); + return newKey; + } + + public static LayoutBitmaps createEmpty() { + return new LayoutBitmaps(); + } + + private static ControlsContainer createEmpty(String controlsJson) { + return new ControlsContainer(controlsJson, new LayoutBitmaps()); + } + + private static ControlsContainer loadFromZip(ZipInputStream zipIn) throws IOException { + LayoutBitmaps layoutBitmaps = new LayoutBitmaps(); + String layoutContent = null; + for(ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn.getNextEntry()) { + if(entry.isDirectory()) continue; + String entryName = entry.getName(); + if(entryName.equals("layout.json")) { + layoutContent = IOUtils.toString(zipIn, StandardCharsets.UTF_8); + continue; + } + layoutBitmaps.mBitmaps.put(entryName, BitmapFactory.decodeStream(zipIn)); + zipIn.closeEntry(); + } + if(layoutContent == null) throw new ZipException("Incorrect ZIP file structure"); + return new ControlsContainer(layoutContent, layoutBitmaps); + } + + private static ControlsContainer load(FileInputStream fileInputStream, long fileSize) throws IOException{ + try(BufferedInputStream bufferedIn = new BufferedInputStream(fileInputStream)) { + boolean isZip; + bufferedIn.mark(4096); + try { + ZipInputStream zipIn = new ZipInputStream(bufferedIn); + isZip = zipIn.getNextEntry() != null; + } catch (ZipException e) { + isZip = false; + } catch (IOException e) { + throw e; + } catch (Exception e) { + isZip = false; + } + bufferedIn.reset(); + if(isZip) { + try(ZipInputStream zipIn = new ZipInputStream(bufferedIn)) { + return loadFromZip(zipIn); + } + } else { + long meg = 1024L * 1024L; + if(fileSize > (25L * meg)) throw new IOException("Raw JSON control data size too large"); + return createEmpty(IOUtils.toString(bufferedIn, StandardCharsets.UTF_8)); + } + } + } + + private static void storeZip(FileOutputStream fileOutputStream, ControlsContainer controlsContainer) throws IOException { + LayoutBitmaps bitmaps = controlsContainer.mLayoutZip; + try(ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream)) { + zipOutputStream.putNextEntry(new ZipEntry("layout.json")); + IOUtils.write(controlsContainer.mControlsJson, zipOutputStream, StandardCharsets.UTF_8); + zipOutputStream.closeEntry(); + for(Map.Entry bitmapEntry : bitmaps.mBitmaps.entrySet()) { + Bitmap outBitmap = bitmapEntry.getValue(); + if(outBitmap == null) continue; + zipOutputStream.putNextEntry(new ZipEntry(bitmapEntry.getKey())); + outBitmap.compress(Bitmap.CompressFormat.WEBP, 100, zipOutputStream); + zipOutputStream.closeEntry(); + } + } + } + + public static void store(FileOutputStream fileOutputStream, ControlsContainer controlsContainer) throws IOException { + LayoutBitmaps bitmaps = controlsContainer.mLayoutZip; + String controlsContent = controlsContainer.mControlsJson; + if(bitmaps.mBitmaps.isEmpty()) { + IOUtils.write(controlsContent, fileOutputStream, StandardCharsets.UTF_8); + return; + } + storeZip(fileOutputStream, controlsContainer); + } + + public static @NonNull ControlsContainer load(File jsonLocation) throws IOException { + try (FileInputStream fileInputStream = new FileInputStream(jsonLocation)) { + return load(fileInputStream, jsonLocation.length()); + } + } + + public static final class ControlsContainer { + public final String mControlsJson; + public final LayoutBitmaps mLayoutZip; + + public ControlsContainer(String mControlsJson, LayoutBitmaps mLayoutZip) { + this.mControlsJson = mControlsJson; + this.mLayoutZip = mLayoutZip; + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java new file mode 100644 index 0000000000..486dde6fe5 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java @@ -0,0 +1,43 @@ +package net.kdt.pojavlaunch.customcontrols.buttons; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Color; +import android.util.TypedValue; + +import androidx.core.graphics.ColorUtils; + +public class BackgroundTint { + public static final int BACKGROUND_DEFAULT_TINT_ALPHA = 60; + public static final int BACKGROUND_TOGGLE_TINT_ALPHA = 128; + + private static int lastTheme = System.identityHashCode(BackgroundTint.class); + + private static final int[][] sState = new int[][] { + new int[] {android.R.attr.state_activated} + }; + private static final int[] sDefaultTint = new int[] { + ColorUtils.setAlphaComponent(Color.WHITE, BACKGROUND_DEFAULT_TINT_ALPHA) + }; + private static final int[] sToggleableTint = new int[] { + ColorUtils.setAlphaComponent(Color.WHITE, BACKGROUND_TOGGLE_TINT_ALPHA) + }; + + public static final ColorStateList DEFAULT_TINT_LIST = new ColorStateList( + sState, sDefaultTint + ); + public static final ColorStateList TOGGLE_TINT_LIST = new ColorStateList( + sState, sToggleableTint + ); + + public static void applyToggleTint(Context context) { + Resources.Theme theme = context.getTheme(); + int themeHash = theme.hashCode(); + if(themeHash == lastTheme) return; + final TypedValue value = new TypedValue(); + theme.resolveAttribute(android.R.attr.colorAccent, value, true); + sToggleableTint[0] = ColorUtils.setAlphaComponent(value.data, BACKGROUND_TOGGLE_TINT_ALPHA); + lastTheme = themeHash; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java index 5fb9d687ba..efa07b234c 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java @@ -15,6 +15,7 @@ import android.view.View; import android.widget.TextView; +import net.kdt.pojavlaunch.EfficientAndroidLWJGLKeycode; import net.kdt.pojavlaunch.LwjglGlfwKeycode; import net.kdt.pojavlaunch.MainActivity; import net.kdt.pojavlaunch.R; @@ -191,7 +192,7 @@ public void sendKeyPresses(boolean isDown){ setActivated(isDown); for(int keycode : mProperties.keycodes){ if(keycode >= GLFW_KEY_UNKNOWN){ - sendKeyPress(keycode, CallbackBridge.getCurrentMods(), isDown); + sendKeyPress(keycode, EfficientAndroidLWJGLKeycode.getLwjglChar(keycode), CallbackBridge.getCurrentMods(), isDown); CallbackBridge.setModifiers(keycode, isDown); }else{ Log.i("punjabilauncher", "sendSpecialKey("+keycode+","+isDown+")"); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java new file mode 100644 index 0000000000..d112644f24 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java @@ -0,0 +1,81 @@ +package net.kdt.pojavlaunch.customcontrols.gamepad; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +import fr.spse.gamepad_remapper.GamepadHandler; +import git.artdeell.dnbootstrap.glfw.GLFW; +import git.artdeell.dnbootstrap.glfw.GamepadKeycodes; + +public class DirectGamepad implements GamepadHandler { + @Override + public void handleGamepadInput(int keycode, float value) { + int gKeycode = -1, gAxis = -1; + boolean normalize = false; + switch (keycode) { + case KeyEvent.KEYCODE_BUTTON_A: gKeycode = GamepadKeycodes.BUTTON_A; break; + case KeyEvent.KEYCODE_BUTTON_B: gKeycode = GamepadKeycodes.BUTTON_B; break; + case KeyEvent.KEYCODE_BUTTON_X: gKeycode = GamepadKeycodes.BUTTON_X; break; + case KeyEvent.KEYCODE_BUTTON_Y: gKeycode = GamepadKeycodes.BUTTON_Y; break; + case KeyEvent.KEYCODE_BUTTON_L1: gKeycode = GamepadKeycodes.BUTTON_LEFT_BUMPER; break; + case KeyEvent.KEYCODE_BUTTON_R1: gKeycode = GamepadKeycodes.BUTTON_RIGHT_BUMPER; break; + case KeyEvent.KEYCODE_BUTTON_L2: + case MotionEvent.AXIS_LTRIGGER: + gAxis = GamepadKeycodes.AXIS_LEFT_TRIGGER; + normalize = true; + break; + case KeyEvent.KEYCODE_BUTTON_R2: + case MotionEvent.AXIS_RTRIGGER: + gAxis = GamepadKeycodes.AXIS_RIGHT_TRIGGER; + normalize = true; + break; + case KeyEvent.KEYCODE_BUTTON_THUMBL: gKeycode = GamepadKeycodes.BUTTON_LEFT_THUMB; break; + case KeyEvent.KEYCODE_BUTTON_THUMBR: gKeycode = GamepadKeycodes.BUTTON_RIGHT_THUMB; break; + case KeyEvent.KEYCODE_BUTTON_START: gKeycode = GamepadKeycodes.BUTTON_START; break; + case KeyEvent.KEYCODE_BUTTON_SELECT: gKeycode = GamepadKeycodes.BUTTON_BACK; break; + case KeyEvent.KEYCODE_DPAD_UP: gKeycode = GamepadKeycodes.BUTTON_DPAD_UP; break; + case KeyEvent.KEYCODE_DPAD_DOWN: gKeycode = GamepadKeycodes.BUTTON_DPAD_DOWN; break; + case KeyEvent.KEYCODE_DPAD_LEFT: gKeycode = GamepadKeycodes.BUTTON_DPAD_LEFT; break; + case KeyEvent.KEYCODE_DPAD_RIGHT: gKeycode = GamepadKeycodes.BUTTON_DPAD_RIGHT; break; + case KeyEvent.KEYCODE_DPAD_CENTER: + // Behave the same way as the Gamepad here, as GLFW doesn't have a keycode + // for the dpad center. + GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_UP, GamepadKeycodes.GLFW_RELEASE); + GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_DOWN, GamepadKeycodes.GLFW_RELEASE); + GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_LEFT, GamepadKeycodes.GLFW_RELEASE); + GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_RIGHT, GamepadKeycodes.GLFW_RELEASE); + return; + case MotionEvent.AXIS_X: gAxis = GamepadKeycodes.AXIS_LEFT_X; break; + case MotionEvent.AXIS_Y: gAxis = GamepadKeycodes.AXIS_LEFT_Y; break; + case MotionEvent.AXIS_Z: gAxis = GamepadKeycodes.AXIS_RIGHT_X; break; + case MotionEvent.AXIS_RZ: gAxis = GamepadKeycodes.AXIS_RIGHT_Y; break; + case MotionEvent.AXIS_HAT_X: + GLFW.gamepadButtonBuffer.put( + GamepadKeycodes.BUTTON_DPAD_LEFT, + value < -0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE + ); + GLFW.gamepadButtonBuffer.put( + GamepadKeycodes.BUTTON_DPAD_RIGHT, + value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE + ); + return; + case MotionEvent.AXIS_HAT_Y: + GLFW.gamepadButtonBuffer.put( + GamepadKeycodes.BUTTON_DPAD_UP, + value < -0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE + ); + GLFW.gamepadButtonBuffer.put( + GamepadKeycodes.BUTTON_DPAD_DOWN, + value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE + ); + return; + } + if(gKeycode != -1) { + GLFW.gamepadButtonBuffer.put(gKeycode, value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE); + } + if(gAxis != -1) { + if(normalize) value = value * 2 - 1; + GLFW.gamepadAxisBuffer.put(gAxis, value); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java index fe97ec601f..e810273e7e 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java @@ -90,6 +90,7 @@ public class Gamepad implements GrabListener, GamepadHandler { private boolean mRemoved = false; public Gamepad(View contextView, InputDevice inputDevice, GamepadDataProvider mapProvider, boolean showCursor){ + Settings.setDeadzoneScale(PREF_DEADZONE_SCALE); mScreenChoreographer = Choreographer.getInstance(); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java index 38f6dd4ca7..9bee64f2d6 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java @@ -1,21 +1,29 @@ package net.kdt.pojavlaunch.customcontrols.mouse; +import static net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF; +import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE; + +import android.content.SharedPreferences; import android.os.Build; import android.view.InputDevice; import android.view.MotionEvent; import android.view.View; import android.view.ViewTreeObserver; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import net.kdt.pojavlaunch.GrabListener; import net.kdt.pojavlaunch.MinecraftGLSurface; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.prefs.LauncherPreferences; import org.lwjgl.glfw.CallbackBridge; +import java.util.function.Consumer; + @RequiresApi(api = Build.VERSION_CODES.O) -public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChangeListener, View.OnCapturedPointerListener { +public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChangeListener, View.OnCapturedPointerListener, GrabListener, SharedPreferences.OnSharedPreferenceChangeListener { private static final float TOUCHPAD_SCROLL_THRESHOLD = 1; private final AbstractTouchpad mTouchpad; private final View mHostView; @@ -32,14 +40,43 @@ public AndroidPointerCapture(AbstractTouchpad touchpad, View hostView) { this.mHostView = hostView; hostView.setOnCapturedPointerListener(this); hostView.getViewTreeObserver().addOnWindowFocusChangeListener(this); + DEFAULT_PREF.registerOnSharedPreferenceChangeListener(this); + CallbackBridge.addGrabListener(this); } + /** + * Checks whether or not the touchpad is already enabled and if user prefers virtual cursor + * if they don't, the touchpad is not enabled + */ private void enableTouchpadIfNecessary() { - if(!mTouchpad.getDisplayState()) mTouchpad.enable(true); + if(!mTouchpad.getDisplayState() && PREF_MOUSE_GRAB_FORCE) mTouchpad.enable(true); + } + + // Needed so it releases the cursor when inside game menu + @Override + public void onGrabState(boolean isGrabbing) { + handleAutomaticCapture(); + } + // It's only here so the side-dialog changes it live + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @Nullable String key) { + if (sharedPreferences.getBoolean("always_grab_mouse", true)){ + enableTouchpadIfNecessary(); + } else mTouchpad.disable(); + handleAutomaticCapture(); } public void handleAutomaticCapture() { - if(!mHostView.hasWindowFocus()) { + // isGrabbing checks for whether we are in menu + if (!CallbackBridge.isGrabbing() + && !PREF_MOUSE_GRAB_FORCE) { + mHostView.releasePointerCapture(); + return; + } + if (mHostView.hasPointerCapture()) { + enableTouchpadIfNecessary(); + } + if (!mHostView.hasWindowFocus()) { mHostView.requestFocus(); } else { mHostView.requestPointerCapture(); @@ -49,28 +86,57 @@ public void handleAutomaticCapture() { @Override public boolean onCapturedPointer(View view, MotionEvent event) { checkSameDevice(event.getDevice()); + int axisX, axisY; + // Sources can claim to be a relative device by belonging to the trackball class, if so then + // we could just use the relative axis directly but some OEMs report absolute touchpads as + // trackballs, verify that it gives us relative input with mDeviceSupportsRelativeAxis and + // hope whatever relative value it spits out is valid, otherwise, revert to non-relative and + // hope the OS does magic + if (mDeviceSupportsRelativeAxis) { + axisX = MotionEvent.AXIS_RELATIVE_X; + axisY = MotionEvent.AXIS_RELATIVE_Y; + } else { + axisX = MotionEvent.AXIS_X; + axisY = MotionEvent.AXIS_Y; + } + // Yes, we actually not only receive relative mouse events here, but also absolute touchpad ones! // Therefore, we need to know when it's a touchpad and when it's a mouse. + if ((event.getSource() & InputDevice.SOURCE_CLASS_TRACKBALL) != 0){ + processBatchEvents(event, axisX, axisY, this::processMousePos); + mVector[0] = event.getAxisValue(axisX); + mVector[1] = event.getAxisValue(axisY); + return processAndSendMotionEvent(event); + } + // If it's not a trackball, it's likely a touchpad and needs tracking like a touchscreen. + processBatchEvents(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y, mPointerTracker::trackEvent); + // Touchscreens should never be using relative axis...right? Well it's an easy fix if there's + // a bug report! + mPointerTracker.trackEvent(event); // This also updates the mVector variable. + return processAndSendMotionEvent(event); + } - if((event.getSource() & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) { - // If the source claims to be a relative device by belonging to the trackball class, - // use its coordinates directly. - if(mDeviceSupportsRelativeAxis) { - // If some OEM decides to do a funny and make an absolute touchpad report itself as - // a trackball, we will at least have semi-valid relative positions - mVector[0] = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); - mVector[1] = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); - }else { - // Otherwise trust the OS, i guess?? - mVector[0] = event.getX(); - mVector[1] = event.getY(); - } - }else { - // If it's not a trackball, it's likely a touchpad and needs tracking like a touchscreen. - mPointerTracker.trackEvent(event); - // The relative position will already be written down into the mVector variable. + /** + * Android handles high refresh rate mice by batching their movements in between screen refresh. + * This basically locks your input hz to what your screen hz is. + * Screw that, handle it as the high hz input that it is, to the extent permitted by the rules. + * Processes historical batched events one by one, sets mVector, and runs {@code moveCursorWithEvent}. + * @param event The MotionEvent + * @param axisX The axis identifier for the axis value to retrieve + * @param axisY The axis identifier for the axis value to retrieve + * @param moveCursorWithEvent Lambda that triggers cursor movement. Runs after mVector update. + */ + private void processBatchEvents(MotionEvent event, int axisX, int axisY, Consumer moveCursorWithEvent){ + // Process batched events first as said in Android docs https://developer.android.com/reference/android/view/MotionEvent#batching + for (int h = 0; h < event.getHistorySize(); h++){ + mVector[0] = event.getHistoricalAxisValue(axisX, h); + mVector[1] = event.getHistoricalAxisValue(axisY, h); + // All historical values are ACTION_MOVE. This is the lamest part. Damn your rules. + moveCursorWithEvent.accept(event); } + } + private void processMousePos(MotionEvent event) { if(!CallbackBridge.isGrabbing()) { enableTouchpadIfNecessary(); // Yes, if the user's touchpad is multi-touch we will also receive events for that. @@ -89,6 +155,10 @@ public boolean onCapturedPointer(View view, MotionEvent event) { CallbackBridge.mouseY += (mVector[1] * LauncherPreferences.PREF_SCALE_FACTOR); CallbackBridge.sendCursorPos(CallbackBridge.mouseX, CallbackBridge.mouseY); } + } + + private boolean processAndSendMotionEvent(MotionEvent event) { + processMousePos(event); switch (event.getActionMasked()) { case MotionEvent.ACTION_MOVE: @@ -128,7 +198,11 @@ private void reinitializeDeviceSpecificProperties(InputDevice inputDevice) { @Override public void onWindowFocusChanged(boolean hasFocus) { - if(hasFocus && Tools.isAndroid8OrHigher()) mHostView.requestPointerCapture(); + if (!CallbackBridge.isGrabbing() // Only capture if not in menu and user said so + && !PREF_MOUSE_GRAB_FORCE) { + return; + } + if (hasFocus && Tools.isAndroid8OrHigher()) mHostView.requestPointerCapture(); } public void detach() { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java new file mode 100644 index 0000000000..f33f047960 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java @@ -0,0 +1,39 @@ +package net.kdt.pojavlaunch.customcontrols.mouse; + +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; + +/** + * Contains cursor data and the draw method + */ +public class CursorContainer { + private final Drawable drawable; + private final int xHotspot; + private final int yHotspot; + + public CursorContainer(Drawable drawable, int xHotspot, int yHotspot) { + this.drawable = drawable; + this.xHotspot = xHotspot; + this.yHotspot = yHotspot; + } + + public void draw(@NonNull Canvas canvas) { + canvas.translate(-xHotspot, -yHotspot); + + drawable.draw(canvas); + } + + public Drawable getDrawable() { + return drawable; + } + + public int getXHotspot() { + return xHotspot; + } + + public int getYHotspot() { + return yHotspot; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java new file mode 100644 index 0000000000..0328718e18 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java @@ -0,0 +1,35 @@ +package net.kdt.pojavlaunch.customcontrols.mouse; + +import android.os.Handler; + +import net.kdt.pojavlaunch.utils.MathUtils; + +public abstract class DistanceGesture extends ValidatorGesture { + + protected float mGestureTravelX, mGestureTravelY; + + public DistanceGesture(Handler mHandler) { + super(mHandler); + } + + public void inputEvent() { + if(!shouldSubmitGesture()) return; + if(submit()) { + mGestureTravelX = 0; + mGestureTravelY = 0; + onGestureSubmitted(); + } + } + + public void setMotion(float deltaX, float deltaY) { + mGestureTravelX += deltaX; + mGestureTravelY += deltaY; + } + + protected boolean travelBelowThreshold(float th) { + return MathUtils.dist(mGestureTravelX, mGestureTravelY) <= th; + } + + abstract void onGestureSubmitted(); + abstract boolean shouldSubmitGesture(); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java index 7a1f71d861..78ca9e7109 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java @@ -132,7 +132,7 @@ public void applyMotionVector(float x, float y) { public void enable(boolean supposed) { if(mDisplayState) return; mDisplayState = true; - if(supposed && CallbackBridge.isGrabbing()) return; + if(supposed && CallbackBridge.isGrabbing() && LauncherPreferences.PREF_MOUSE_GRAB_FORCE) return; _enable(); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java new file mode 100644 index 0000000000..24f229ebf7 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java @@ -0,0 +1,15 @@ +package net.kdt.pojavlaunch.downloader; + +import java.io.IOException; + +public abstract class AcquireableTaskMetadata extends TaskMetadata { + public AcquireableTaskMetadata(int mirrorType) { + super(null, null, mirrorType); + } + + /** + * Fill the missing fields of this AcquireableTaskMetadata (by, for example, performing an API request) + * @throws IOException if metadata acquisition failed + */ + public abstract void acquireMetadata() throws IOException; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java new file mode 100644 index 0000000000..0ee9f431f4 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java @@ -0,0 +1,5 @@ +package net.kdt.pojavlaunch.downloader; + +public interface BytesCopiedListener { + void onBytesCopied(int nbytes); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java new file mode 100644 index 0000000000..53dbec7b76 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java @@ -0,0 +1,44 @@ +package net.kdt.pojavlaunch.downloader; + +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.HashUtils; + +import java.io.File; +import java.io.IOException; + +public class CheckFileOnDiskTask extends DownloaderTask { + private final boolean mAfterDownload; + CheckFileOnDiskTask(TaskMetadata mMetadata, Downloader mHostDownloader) { + super(mMetadata, mHostDownloader); + this.mAfterDownload = false; + } + + CheckFileOnDiskTask(TaskMetadata mMetadata, Downloader mHostDownloader, boolean mAfterDownload) { + super(mMetadata, mHostDownloader); + this.mAfterDownload = mAfterDownload; + } + + @Override + protected void performTask() throws IOException { + boolean checkResult = checkFile(); + if(checkResult) { + if(!mAfterDownload) mDownloader.addSize(mMetadata.size); + mDownloader.fileComplete(); + }else { + if(!mAfterDownload) mDownloader.submitFileForDownload(mMetadata); + else throw new IOException("Failed to verify "+mMetadata.toString()); + } + } + + private boolean checkFile() throws IOException { + File localFile = mMetadata.path; + if(!localFile.exists()) return false; + if(!LauncherPreferences.PREF_VERIFY_FILES) return true; + if(mMetadata.size != -1) { + if(mMetadata.size != localFile.length()) return false; + if(LauncherPreferences.PREF_RAPID_START && !mAfterDownload) return true; + } + return mMetadata.sha1Hash == null || HashUtils.compareSHA1(localFile, mMetadata.sha1Hash); + } + +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java new file mode 100644 index 0000000000..1602a1c633 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java @@ -0,0 +1,61 @@ +package net.kdt.pojavlaunch.downloader; + +import android.util.Log; + +import net.kdt.pojavlaunch.mirrors.DownloadMirror; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; + +import java.io.IOException; +import java.net.URL; + +public class CompleteMetadataTask extends DownloaderTask { + CompleteMetadataTask(TaskMetadata mMetadata, Downloader mHostDownloader) { + super(mMetadata, mHostDownloader); + } + + @Override + protected void performTask() throws IOException { + if(mMetadata instanceof AcquireableTaskMetadata) { + ((AcquireableTaskMetadata)mMetadata).acquireMetadata(); + if(mMetadata.url == null) throw new IOException("Metadata acquisition did not supply the URL!"); + } + if(mMetadata.url != null) { + getFileSize(); + getLibrarySha1Hash(); + } + if(mMetadata.size == -1) { + mDownloader.disableSizeCounter(); + } + mDownloader.fileComplete(); + } + + private void getLibrarySha1Hash() { + if(mMetadata.sha1Hash != null) return; + if(mMetadata.mirrorType != DownloadMirror.DOWNLOAD_CLASS_LIBRARIES) return; + + if(!LauncherPreferences.PREF_VERIFY_FILES) return; + + // No need to try and obtain the hash if the file is qualified for rapid start check skip + if(LauncherPreferences.PREF_RAPID_START && mMetadata.size != -1 && mMetadata.path.length() == mMetadata.size) return; + + try { + mMetadata.sha1Hash = mDownloader.downloadString(new URL(mMetadata.url + ".sha1")); + }catch (IOException e) { + Log.i("CompleteMetadataTask", "Failed to get server hash for "+mMetadata.path.getName(), e); + } + } + + private void getFileSize() { + if(mMetadata.size != -1) return; + try { + mMetadata.size = mDownloader.getFileContentLength(mMetadata.url); + Log.i("CompleteMetadataTask", "Got size: " + mMetadata.size +" for " + mMetadata.path.getName()); + }catch (IOException e) { + Log.i("CompleteMetadataTask", "Failed to get size for " + mMetadata.path.getName(), e); + } + } + + protected static boolean shouldCompleteMetadata(TaskMetadata metadata) { + return metadata instanceof AcquireableTaskMetadata || (metadata.sha1Hash == null && metadata.mirrorType == DownloadMirror.DOWNLOAD_CLASS_LIBRARIES) || metadata.size == -1; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java new file mode 100644 index 0000000000..5a0450b0aa --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java @@ -0,0 +1,46 @@ +package net.kdt.pojavlaunch.downloader; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicLong; + +public class DownloadFileTask extends DownloaderTask implements BytesCopiedListener { + private final AtomicLong mBytesDownloaded = new AtomicLong(); + DownloadFileTask(TaskMetadata mMetadata, Downloader mHostDownloader) { + super(mMetadata, mHostDownloader); + } + + @Override + protected void performTask() throws IOException { + tryDownload(0, true); + mDownloader.submitFileForRecheck(mMetadata); + } + + private void performRetry(int attempt, boolean rangeAllowed) throws IOException{ + mDownloader.addSize(-mBytesDownloaded.get()); // It will get readded again on next tryDownload() if range is allowed + tryDownload(attempt + 1, rangeAllowed); + } + + private void tryDownload(int attempt, boolean rangeAllowed) throws IOException { + try { + if(!mMetadata.path.exists() || !rangeAllowed) { + mBytesDownloaded.set(0); + mDownloader.downloadFile(mMetadata.path, mMetadata.url, this); + } else { + long alreadyDownloaded = mMetadata.path.length(); + mBytesDownloaded.set(alreadyDownloaded); + mDownloader.addSize(alreadyDownloaded); + rangeAllowed = mDownloader.tryContinueDownload(mMetadata.path, mMetadata.size, mMetadata.url, this); + if(!rangeAllowed) performRetry(attempt, false); + } + }catch (IOException e) { + if(attempt == 5) throw e; + performRetry(attempt, rangeAllowed); + } + } + + @Override + public void onBytesCopied(int nbytes) { + mBytesDownloaded.getAndAdd(nbytes); + mDownloader.addSize(nbytes); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java new file mode 100644 index 0000000000..3b4af80f03 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java @@ -0,0 +1,238 @@ +package net.kdt.pojavlaunch.downloader; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.tasks.SpeedCalculator; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import git.artdeell.mojo.R; + +public class Downloader { + private static final double ONE_MEGABYTE = (1024d * 1024d); + private static final ThreadLocal sThreadLocalBuffer = new ThreadLocal<>(); + private final String mProgressKey; + private final AtomicReference mThreadException = new AtomicReference<>(); + private final AtomicInteger mDownloadedFileCounter = new AtomicInteger(); + private final AtomicLong mDownloadedSizeCounter = new AtomicLong(); + private final AtomicLong mInternetUsageCounter = new AtomicLong(); + private final AtomicBoolean mUseSizeProgress = new AtomicBoolean(true); + private final SpeedCalculator mSpeedCalculator = new SpeedCalculator(); + private ExecutorService mDownloadService; + private ExecutorService mVerifyService; + + public Downloader(String mProgressKey) { + this.mProgressKey = mProgressKey; + } + + protected void runDownloads(ArrayList downloads) throws IOException, InterruptedException { + insertMetadata(downloads); + performDownloads(downloads); + } + + private void performDownloads(ArrayList metadata) throws IOException, InterruptedException { + mThreadException.set(null); + mDownloadedFileCounter.set(0); + mDownloadedSizeCounter.set(0); + mDownloadService = Executors.newFixedThreadPool(3); + int verifyThreads = Math.max(2, Runtime.getRuntime().availableProcessors() - 2); + mVerifyService = Executors.newFixedThreadPool(verifyThreads, r -> { + Thread thread = new Thread(r); + thread.setPriority(10); + thread.setName("verify thread"); + return thread; + }); + long totalSize = 0; + int totalCount = metadata.size(); + boolean sizeCounter = mUseSizeProgress.get(); + for(TaskMetadata element : metadata) { + totalSize += element.size; + mVerifyService.submit(new CheckFileOnDiskTask(element, this)); + } + double totalMegabytes = totalSize / ONE_MEGABYTE; + while(mDownloadedFileCounter.get() < totalCount) { + IOException exception = mThreadException.get(); + if(exception != null) throw exception; + if(sizeCounter) reportSizeProgress(totalMegabytes); + else reportCountProgress(R.string.newerdl_downloading_files_count, totalCount); + Thread.sleep(33); + } + mDownloadService.shutdown(); + mVerifyService.shutdown(); + if(!mDownloadService.awaitTermination(100, TimeUnit.MILLISECONDS) || + !mVerifyService.awaitTermination(100, TimeUnit.MILLISECONDS)) { + throw new RuntimeException("BUG! The file counter is wrong. Maybe. Send this to artDev."); + } + } + + private void insertMetadata(ArrayList metadata) throws IOException, InterruptedException { + mThreadException.set(null); + mDownloadedFileCounter.set(0); + ArrayList reducedList = new ArrayList<>(); + for(TaskMetadata element : metadata) { + if(!CompleteMetadataTask.shouldCompleteMetadata(element)) continue; + reducedList.add(element); + } + if(reducedList.isEmpty()) return; + try (ExecutorService executorService = Executors.newFixedThreadPool(4)) { + for(TaskMetadata element : reducedList) executorService.submit(new CompleteMetadataTask(element, this)); + executorService.shutdown(); + while (!executorService.awaitTermination(33, TimeUnit.MILLISECONDS)) { + IOException exception = mThreadException.get(); + if(exception != null) throw exception; + reportCountProgress(R.string.newerdl_inserting_metadata_count, reducedList.size()); + } + } + } + + private double getSpeed() { + return mSpeedCalculator.feed(mInternetUsageCounter.get()) / ONE_MEGABYTE; + } + + private void reportCountProgress(int resource, int total) { + int downloadedCount = mDownloadedFileCounter.get(); + int progress = (int) ((downloadedCount / (float)total) * 100f); + ProgressLayout.setProgress(mProgressKey, progress, resource, + downloadedCount, total, getSpeed() + ); + } + + private void reportSizeProgress(double totalMegabytes) { + double downloadedMegabytes = mDownloadedSizeCounter.get() / ONE_MEGABYTE; + int progress = (int) (downloadedMegabytes / totalMegabytes * 100d); + ProgressLayout.setProgress(mProgressKey, progress, R.string.newerdl_downloading_files_size, + downloadedMegabytes, totalMegabytes, getSpeed() + ); + } + + protected void taskException(IOException e) { + mThreadException.set(e); + } + + protected void disableSizeCounter() { + mUseSizeProgress.lazySet(false); + } + + protected void submitFileForDownload(TaskMetadata taskMetadata) { + mDownloadService.submit(new DownloadFileTask(taskMetadata, this)); + } + + protected void submitFileForRecheck(TaskMetadata taskMetadata) { + mVerifyService.submit(new CheckFileOnDiskTask(taskMetadata, this, true)); + } + + protected void fileComplete() { + mDownloadedFileCounter.getAndIncrement(); + } + + protected void addSize(long bytes) { + mDownloadedSizeCounter.getAndAdd(bytes); + } + + private void copy(InputStream inputStream, OutputStream outputStream, BytesCopiedListener listener) throws IOException { + byte[] buffer = getBuffer(); + int readLen; + while((readLen = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readLen); + if(listener != null) listener.onBytesCopied(readLen); + mInternetUsageCounter.getAndAdd(readLen); + } + } + + private static HttpURLConnection openConnection(URL url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setReadTimeout(10000); + connection.setRequestProperty("User-Agent", DownloadUtils.USER_AGENT); + connection.setDoInput(true); + connection.setDoOutput(false); + return connection; + } + + protected void downloadToStream(HttpURLConnection connection, OutputStream outputStream, BytesCopiedListener listener) throws IOException { + InputStream inputStream = connection.getInputStream(); + copy(inputStream, outputStream, listener); + } + + protected String downloadString(URL url) throws IOException { + HttpURLConnection connection = openConnection(url); + int length = connection.getContentLength(); + if(length < 0) length = 32; + try(ByteArrayOutputStream outputStream = new ByteArrayOutputStream(length)) { + downloadToStream(connection, outputStream, null); + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + }finally { + connection.disconnect(); + } + } + + protected void downloadFile(File file, URL url, BytesCopiedListener listener) throws IOException { + HttpURLConnection connection = openConnection(url); + try(FileOutputStream outputStream = new FileOutputStream(file)) { + downloadToStream(connection, outputStream, listener); + }finally { + connection.disconnect(); + } + } + + protected boolean tryContinueDownload(File file, long wantedLength, URL url, BytesCopiedListener listener) throws IOException { + HttpURLConnection connection = openConnection(url); + String range = String.format(Locale.ENGLISH,"bytes %d-%d/%d", file.length(), wantedLength-1, wantedLength); + connection.setRequestProperty("Content-Range", range); + try { + connection.connect(); + int responseCode = connection.getResponseCode(); + if(responseCode != 206) { + return false; + } + try(FileOutputStream outputStream = new FileOutputStream(file, true)) { + downloadToStream(connection, outputStream, listener); + return true; + } + }finally { + connection.disconnect(); + } + } + + protected long getFileContentLength(URL url) throws IOException { + HttpURLConnection connection = openConnection(url); + + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + + connection.setRequestMethod("HEAD"); + connection.connect(); + int response = connection.getResponseCode(); + if(response >= 400) { + return -1; + }else { + return connection.getContentLength(); + } + } + + public static byte[] getBuffer() { + byte[] buffer = sThreadLocalBuffer.get(); + if(buffer == null) { + buffer = new byte[8192]; + sThreadLocalBuffer.set(buffer); + } + return buffer; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java new file mode 100644 index 0000000000..6f6b796c15 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java @@ -0,0 +1,24 @@ +package net.kdt.pojavlaunch.downloader; + +import java.io.IOException; + +public abstract class DownloaderTask implements Runnable { + protected final TaskMetadata mMetadata; + protected final Downloader mDownloader; + + protected DownloaderTask(TaskMetadata mMetadata, Downloader mHostDownloader) { + this.mMetadata = mMetadata; + this.mDownloader = mHostDownloader; + } + + @Override + public final void run() { + try { + performTask(); + }catch (IOException e) { + mDownloader.taskException(e); + } + } + + protected abstract void performTask() throws IOException; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java new file mode 100644 index 0000000000..39e0aacdf1 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java @@ -0,0 +1,32 @@ +package net.kdt.pojavlaunch.downloader; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.net.URL; + +public class TaskMetadata { + public File path; + public URL url; + public final int mirrorType; + public long size; + public String sha1Hash; + + public TaskMetadata(File path, URL url, int mirrorType) { + this.path = path; + this.url = url; + this.mirrorType = mirrorType; + } + + public TaskMetadata(File path, URL url, long size, String hash, int mirrorType) { + this(path, url, mirrorType); + this.sha1Hash = hash; + this.size = size; + } + + @NonNull + @Override + public String toString() { + return "TaskMetadata{\nurl="+url+";\npath="+path+"\nhash="+sha1Hash+";\nsize="+size+"\n}"; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java new file mode 100644 index 0000000000..68143392c5 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java @@ -0,0 +1,43 @@ +package net.kdt.pojavlaunch.fragments; + +import android.app.Dialog; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import git.artdeell.mojo.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.instances.Instance; +import net.kdt.pojavlaunch.instances.Instances; +import net.kdt.pojavlaunch.instances.InstanceIconProvider; +import java.io.IOException; + + +public class DeleteConfirmDialogFragment extends DialogFragment { + private final Instance mInstance = Instances.loadSelectedInstance(); + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + if (mInstance == null) dismiss(); + return new AlertDialog.Builder(requireContext()) + .setTitle(R.string.instance_delete) + .setMessage(R.string.instance_delete_confirmation) + .setPositiveButton(R.string.global_delete, (dialog, which) -> { + if (mInstance == null) return; + InstanceIconProvider.dropIcon(mInstance); + Tools.removeCurrentFragment(requireActivity()); + try { + Instances.removeInstance(mInstance); + } catch (IOException e) { + Tools.showErrorRemote(e); + } + }) + .setNegativeButton(R.string.global_no, null) + .create(); + } + public static String TAG = "delete_dialog_confirm"; +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java new file mode 100644 index 0000000000..2146dc88f8 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java @@ -0,0 +1,16 @@ +package net.kdt.pojavlaunch.fragments; + +import net.kdt.pojavlaunch.extra.ExtraConstants; + +public class ElyByLoginFragment extends OAuthFragment { + public static final String TAG = "ELYBY_LOGIN_FRAGMENT"; + public ElyByLoginFragment() { + super("internalredirect", + "https://account.ely.by/oauth2/v1" + + "?client_id=mojolauncher2" + + "&redirect_uri=internalredirect%3A%2F%2Fcomplete" + + "&response_type=code" + + "&scope=account_info%20offline_access%20minecraft_server_session", + ExtraConstants.ELYBY_LOGIN_TODO); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java new file mode 100644 index 0000000000..6bc79dcceb --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java @@ -0,0 +1,61 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.view.LayoutInflater; +import android.widget.ExpandableListAdapter; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.instances.InstanceInstaller; +import net.kdt.pojavlaunch.instances.Instances; +import net.kdt.pojavlaunch.modloaders.ForgelikeUtils; +import net.kdt.pojavlaunch.modloaders.ForgelikeVersionListAdapter; +import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public abstract class ForgelikeInstallFragment extends ModVersionListFragment> { + private final ForgelikeUtils mUtils; + public ForgelikeInstallFragment(ForgelikeUtils utils, String mFragmentTag) { + super(mFragmentTag); + this.mUtils = utils; + } + + @Override + public List loadVersionList() throws IOException { + return mUtils.downloadVersions(); + } + + @Override + public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) { + return ()->createInstance((String) selectedVersion, listenerProxy); + } + + @Override + public ExpandableListAdapter createAdapter(List versionList, LayoutInflater layoutInflater) { + return new ForgelikeVersionListAdapter(versionList, layoutInflater, mUtils); + } + + @Override + public void onDownloadFinished(Context context, File downloadedFile) { + } + + private void createInstance(String selectedVersion, ModloaderListenerProxy listenerProxy) { + try { + ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0); + InstanceInstaller instanceInstaller = mUtils.createInstaller(selectedVersion); + Instances.createInstance(instance -> { + instance.name = mUtils.getName(); + instance.icon = mUtils.getIconName(); + instance.installer = instanceInstaller; + }, selectedVersion); + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + instanceInstaller.start(); + listenerProxy.onDownloadFinished(null); + }catch (IOException e) { + listenerProxy.onDownloadError(e); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java new file mode 100644 index 0000000000..8ce31ec537 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java @@ -0,0 +1,246 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import git.artdeell.mojo.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.extra.ExtraConstants; +import net.kdt.pojavlaunch.extra.ExtraCore; +import net.kdt.pojavlaunch.instances.Instance; +import net.kdt.pojavlaunch.instances.Instances; +import net.kdt.pojavlaunch.multirt.MultiRTUtils; +import net.kdt.pojavlaunch.multirt.RTSpinnerAdapter; +import net.kdt.pojavlaunch.multirt.Runtime; +import net.kdt.pojavlaunch.instances.InstanceIconProvider; +import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; +import net.kdt.pojavlaunch.utils.CropperUtils; +import net.kdt.pojavlaunch.utils.RendererCompatUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class InstanceEditorFragment extends Fragment implements CropperUtils.CropperReceiver { + public static final String TAG = "InstanceEditorFragment"; + + private Instance mInstance; + private String mSelectedControlLayout; + private Button mSaveButton, mDeleteButton, mControlSelectButton, mVersionSelectButton; + private Spinner mDefaultRuntime, mDefaultRenderer; + private EditText mDefaultName, mDefaultJvmArgument; + private TextView mDefaultVersion, mDefaultControl; + private ImageView mInstanceIcon; + private CheckBox mSharedDataCheckbox; + private int mRecommendedIconSize; + private final ActivityResultLauncher mCropperLauncher = CropperUtils.registerCropper(this, this); + + private List mRenderNames; + + public InstanceEditorFragment(){ + super(R.layout.fragment_instance_editor); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + // Paths, which can be changed + String value = (String) ExtraCore.consumeValue(ExtraConstants.FILE_SELECTOR); + if(value != null){ + mSelectedControlLayout = value; + } + return super.onCreateView(inflater, container, savedInstanceState); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + bindViews(view); + + RendererCompatUtil.RenderersList renderersList = RendererCompatUtil.getCompatibleRenderers(view.getContext()); + mRenderNames = renderersList.rendererIds; + List renderList = new ArrayList<>(renderersList.rendererDisplayNames.length + 1); + renderList.addAll(Arrays.asList(renderersList.rendererDisplayNames)); + renderList.add(view.getContext().getString(R.string.global_default)); + mDefaultRenderer.setAdapter(new ArrayAdapter<>(view.getContext(), R.layout.item_simple_list_1, renderList)); + + // Set up behaviors + mSaveButton.setOnClickListener(v -> { + InstanceIconProvider.dropIcon(mInstance); + save(); + Tools.backToMainMenu(requireActivity()); + }); + + mDeleteButton.setOnClickListener(v -> { + DeleteConfirmDialogFragment dialogFragment = new DeleteConfirmDialogFragment(); + dialogFragment.show(getChildFragmentManager(), "delete_dialog_confirm"); + }); + + View.OnClickListener controlSelectListener = getControlSelectListener(); + mControlSelectButton.setOnClickListener(controlSelectListener); + mDefaultControl.setOnClickListener(controlSelectListener); + + // Setup the expendable list behavior + View.OnClickListener versionSelectListener = getVersionSelectListener(); + mVersionSelectButton.setOnClickListener(versionSelectListener); + mDefaultVersion.setOnClickListener(versionSelectListener); + + // Set up the icon change click listener + mInstanceIcon.setOnClickListener(v -> { + // Fill recommended size on click to ge the most up to date data + mRecommendedIconSize = Math.max(v.getWidth(), v.getHeight()); + CropperUtils.startCropper(mCropperLauncher); + }); + + mSharedDataCheckbox.setOnCheckedChangeListener((v,checked) ->{ + mInstance.sharedData = checked; + int text = R.string.instance_shared_data_off; + if(checked) text = R.string.instance_shared_data_on; + mSharedDataCheckbox.setText(text); + }); + + Instance selectedInstance = Instances.loadSelectedInstance(); + Context context = view.getContext(); + if(selectedInstance == null) { + Toast.makeText(context, R.string.no_instance, Toast.LENGTH_LONG).show(); + getParentFragmentManager().popBackStack(); + }else { + loadValues(selectedInstance, context); + } + } + + private View.OnClickListener getControlSelectListener() { + return v -> { + Bundle bundle = new Bundle(3); + bundle.putBoolean(FileSelectorFragment.BUNDLE_SELECT_FOLDER, false); + bundle.putString(FileSelectorFragment.BUNDLE_ROOT_PATH, Tools.CTRLMAP_PATH); + + Tools.swapFragment(requireActivity(), + FileSelectorFragment.class, FileSelectorFragment.TAG, bundle); + }; + } + + private View.OnClickListener getVersionSelectListener() { + return v -> VersionSelectorDialog.open(v.getContext(), false, (id, snapshot)-> mDefaultVersion.setText(id)); + } + + private static String nullToEmpty(String in) { + if(in == null) return ""; + return in; + } + + private void loadValues(@NonNull Instance instance, @NonNull Context context){ + mInstance = instance; + mInstanceIcon.setImageDrawable( + InstanceIconProvider.fetchIcon(getResources(), instance) + ); + + // Runtime spinner + List runtimes = MultiRTUtils.getRuntimes(); + int jvmIndex = -1; + if(instance.selectedRuntime != null) { + jvmIndex = runtimes.indexOf(new Runtime(instance.selectedRuntime)); + } + mDefaultRuntime.setAdapter(new RTSpinnerAdapter(context, runtimes)); + if(jvmIndex == -1) jvmIndex = runtimes.size() - 1; + mDefaultRuntime.setSelection(jvmIndex); + + // Renderer spinner + int rendererIndex = mRenderNames.indexOf(instance.getLaunchRenderer()); + if(rendererIndex == -1) { + rendererIndex = mDefaultRenderer.getAdapter().getCount() - 1; + } + mDefaultRenderer.setSelection(rendererIndex); + + mDefaultVersion.setText(instance.versionId); + mDefaultJvmArgument.setText(nullToEmpty(instance.jvmArgs)); + mDefaultName.setText(nullToEmpty(instance.name)); + mDefaultControl.setText(mSelectedControlLayout == null ? nullToEmpty(instance.controlLayout) : mSelectedControlLayout); + mSharedDataCheckbox.setChecked(instance.sharedData); + } + + private void bindViews(@NonNull View view){ + mDefaultControl = view.findViewById(R.id.vprof_editor_ctrl_spinner); + mDefaultRuntime = view.findViewById(R.id.vprof_editor_spinner_runtime); + mDefaultRenderer = view.findViewById(R.id.vprof_editor_instance_renderer); + mDefaultVersion = view.findViewById(R.id.vprof_editor_version_spinner); + + mDefaultName = view.findViewById(R.id.vprof_editor_instance_name); + mDefaultJvmArgument = view.findViewById(R.id.vprof_editor_jre_args); + + mSaveButton = view.findViewById(R.id.vprof_editor_save_button); + mDeleteButton = view.findViewById(R.id.vprof_editor_delete_button); + mControlSelectButton = view.findViewById(R.id.vprof_editor_ctrl_button); + mVersionSelectButton = view.findViewById(R.id.vprof_editor_version_button); + mInstanceIcon = view.findViewById(R.id.vprof_editor_instance_icon); + mSharedDataCheckbox = view.findViewById(R.id.vprof_editor_data_checkbox_container); + } + + private void save(){ + //First, check for potential issues in the inputs + mInstance.versionId = mDefaultVersion.getText().toString(); + mInstance.controlLayout = mDefaultControl.getText().toString(); + mInstance.name = mDefaultName.getText().toString(); + mInstance.jvmArgs = mDefaultJvmArgument.getText().toString(); + + if(mInstance.controlLayout.isEmpty()) mInstance.controlLayout = null; + if(mInstance.jvmArgs.isEmpty()) mInstance.jvmArgs = null; + + Runtime selectedRuntime = (Runtime) mDefaultRuntime.getSelectedItem(); + mInstance.selectedRuntime = (selectedRuntime.name.equals("") || selectedRuntime.versionString == null) + ? null : selectedRuntime.name; + + if(mDefaultRenderer.getSelectedItemPosition() == mRenderNames.size()) mInstance.renderer = null; + else mInstance.renderer = mRenderNames.get(mDefaultRenderer.getSelectedItemPosition()); + + try { + mInstance.write(); + }catch (IOException e) { + Tools.showErrorRemote(e); + } + } + + @Override + public float getAspectRatio() { + return 1f; + } + + @Override + public int getTargetMaxSide() { + return mRecommendedIconSize; + } + + @Override + public void onCropped(Bitmap contentBitmap) { + mInstanceIcon.setImageBitmap(contentBitmap); + Log.i("bitmap", "w="+contentBitmap.getWidth() +" h="+contentBitmap.getHeight()); + try { + mInstance.encodeNewIcon(contentBitmap); + }catch (IOException e) { + Tools.showErrorRemote(e); + } + } + + @Override + public void onFailed(Exception exception) { + Tools.showErrorRemote(exception); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java new file mode 100644 index 0000000000..3db486e051 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java @@ -0,0 +1,157 @@ +package net.kdt.pojavlaunch.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.profiles.ProfileAdapter; +import net.kdt.pojavlaunch.profiles.ProfileAdapterExtra; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Shown in the right pane (landscape) when the user taps the instance spinner. + * Uses the same item_version_profile_layout + ProfileAdapter.setView() as the + * spinner popup, so icon sizing is identical. Adds a "New instance" row at top. + */ +public class InstancePickerFragment extends Fragment { + + public static final String TAG = "InstancePickerFragment"; + + public InstancePickerFragment() { + super(R.layout.fragment_instance_picker); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + // Back button — delegates to activity so the right pane pops correctly + view.findViewById(R.id.instance_picker_back) + .setOnClickListener(v -> requireActivity().onBackPressed()); + + RecyclerView recycler = view.findViewById(R.id.instance_picker_recycler); + recycler.setLayoutManager(new LinearLayoutManager(requireContext())); + + LauncherProfiles.load(); + Map profiles = LauncherProfiles.mainProfileJson.profiles; + List keys = new ArrayList<>(profiles.keySet()); + String selected = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, ""); + + ProfileAdapter profileAdapter = new ProfileAdapter(new ProfileAdapterExtra[0]); + + recycler.setAdapter(new InstanceAdapter(keys, profiles, profileAdapter, selected, + profileKey -> { + Fragment parentFrag = getParentFragment(); + if (parentFrag instanceof MainMenuFragment) { + ((MainMenuFragment) parentFrag).selectInstance(profileKey); + } + }, + () -> { + // "New instance" tapped — open profile type selector in right pane + Fragment parentFrag = getParentFragment(); + if (parentFrag instanceof MainMenuFragment) { + ((MainMenuFragment) parentFrag).openChildPane( + ProfileTypeSelectFragment.class, + ProfileTypeSelectFragment.TAG, null); + } else { + Tools.swapFragment(requireActivity(), + ProfileTypeSelectFragment.class, + ProfileTypeSelectFragment.TAG, null); + } + })); + } + + // ── Adapter ────────────────────────────────────────────────────────────── + + interface OnInstanceSelected { void onSelected(String profileKey); } + interface OnCreateNew { void onCreate(); } + + static class InstanceAdapter extends RecyclerView.Adapter { + + private static final int VIEW_TYPE_CREATE = 0; + private static final int VIEW_TYPE_INSTANCE = 1; + + private final List mKeys; + private final Map mProfiles; + private final ProfileAdapter mProfileAdapter; // used for rendering via setView + private String mSelectedKey; + private final OnInstanceSelected mOnSelect; + private final OnCreateNew mOnCreate; + + InstanceAdapter(List keys, Map profiles, + ProfileAdapter profileAdapter, String selectedKey, + OnInstanceSelected onSelect, OnCreateNew onCreate) { + mKeys = keys; + mProfiles = profiles; + mProfileAdapter = profileAdapter; + mSelectedKey = selectedKey; + mOnSelect = onSelect; + mOnCreate = onCreate; + } + + @Override + public int getItemViewType(int position) { + return position == 0 ? VIEW_TYPE_CREATE : VIEW_TYPE_INSTANCE; + } + + @Override + public int getItemCount() { + return mKeys.size() + 1; // +1 for "New instance" + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + // Use the exact same layout the spinner popup uses + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_version_profile_layout, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH h, int position) { + if (getItemViewType(position) == VIEW_TYPE_CREATE) { + // Render the "New instance" extra via ProfileAdapter + ProfileAdapterExtra extra = new ProfileAdapterExtra( + 0, + R.string.create_profile, + h.itemView.getContext().getDrawable(R.drawable.ic_add)); + mProfileAdapter.setViewExtra(h.itemView, extra); + h.itemView.setOnClickListener(v -> mOnCreate.onCreate()); + return; + } + + String key = mKeys.get(position - 1); // offset by 1 for create row + MinecraftProfile p = mProfiles.get(key); + + // Let ProfileAdapter.setView() handle icon + label — identical to spinner popup + mProfileAdapter.setView(h.itemView, key, key.equals(mSelectedKey)); + + h.itemView.setOnClickListener(v -> { + String prev = mSelectedKey; + mSelectedKey = key; + notifyItemChanged(mKeys.indexOf(prev) + 1); + notifyItemChanged(position); + mOnSelect.onSelected(key); + }); + } + + static class VH extends RecyclerView.ViewHolder { + VH(@NonNull View v) { super(v); } + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java new file mode 100644 index 0000000000..e20fbeace0 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java @@ -0,0 +1,11 @@ +package net.kdt.pojavlaunch.fragments; + +import net.kdt.pojavlaunch.modloaders.FabriclikeUtils; + +public class LegacyFabricInstallFragment extends FabriclikeInstallFragment { + + public static final String TAG = "LegacyFabricInstallFragment"; + public LegacyFabricInstallFragment() { + super(FabriclikeUtils.LEGACY_FABRIC_UTILS, TAG); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java index 86a00e3759..e4e2e5d4f4 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch.fragments; +import static net.kdt.pojavlaunch.Tools.hasOnlineProfile; + import android.content.Context; import android.os.Bundle; import android.view.View; @@ -31,6 +33,10 @@ public LocalLoginFragment(){ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + // This is overkill but meh + if (!hasOnlineProfile()){ + Tools.swapFragment(requireActivity(), MainMenuFragment.class, MainMenuFragment.TAG, null); + } mUsernameEditText = view.findViewById(R.id.login_edit_email); view.findViewById(R.id.login_button).setOnClickListener(v -> { if(!checkEditText()) { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java index c760ecfa88..4988e02892 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch.fragments; +import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; +import static net.kdt.pojavlaunch.Tools.hasOnlineProfile; import static net.kdt.pojavlaunch.Tools.openPath; import static net.kdt.pojavlaunch.Tools.shareLog; @@ -7,9 +9,11 @@ import android.os.Bundle; import android.view.View; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; @@ -32,60 +36,326 @@ public class MainMenuFragment extends Fragment { public static final String TAG = "MainMenuFragment"; private mcVersionSpinner mVersionSpinner; + private FrameLayout mRightPane; + private View mBottomBarBg; // stub — kept so mTaskCountListener check compiles + private View mPlayButton; + private View mEditProfileButton; + private View mBottomBar; // the single LinearLayout container for the whole bar + // Intercepts Back when the right pane has something above home + private OnBackPressedCallback mRightPaneBackCallback; - public MainMenuFragment(){ + // ─── Two-pane helpers ──────────────────────────────────────────────────── + + /** True when the two-pane landscape layout is active. */ + private boolean isTwoPane() { + return mRightPane != null; + } + + /** + * True when the right pane has a non-home fragment on the back stack. + * Used by LauncherActivity to decide gear = home vs gear = settings. + */ + public boolean isRightPaneActive() { + return isTwoPane() && getChildFragmentManager().getBackStackEntryCount() > 0; + } + + /** + * Pops one entry off the right pane back stack. + * Called from LauncherActivity.onBackPressed(). + */ + public void popRightPane() { + if (!isTwoPane()) return; + if (getChildFragmentManager().getBackStackEntryCount() > 0) { + getChildFragmentManager().popBackStack(); + } + } + + /** + * Pops everything off the right pane back stack so the home fragment shows again. + * Safe to call even if back stack is empty. + */ + public void clearRightPane() { + if (!isTwoPane()) return; + int count = getChildFragmentManager().getBackStackEntryCount(); + if (count > 0) { + getChildFragmentManager().popBackStack( + getChildFragmentManager().getBackStackEntryAt(0).getName(), + androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE); + } + } + + /** Shows/hides the entire bottom bar. GONE collapses it so right pane fills full height. */ + private void setBottomBarVisible(boolean visible) { + if (mBottomBar != null) + mBottomBar.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + // Note: play button visibility during downloads is handled by the activity's + // ProgressLayout — we do not need a separate TaskCountListener here. + /** + * Called by InstancePickerFragment after the user taps an instance. + * Saves the selection, refreshes the spinner display, and pops back to home. + */ + public void selectInstance(String profileKey) { + LauncherPreferences.DEFAULT_PREF.edit() + .putString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, profileKey) + .apply(); + ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, profileKey); + clearRightPane(); + if (mVersionSpinner != null) mVersionSpinner.reloadProfiles(); + } + + /** Called externally (e.g. ProfileEditorFragment) to refresh the spinner display. */ + public void reloadSpinner() { + if (mVersionSpinner != null) mVersionSpinner.reloadProfiles(); + } + + /** + * Called by child fragments inside the right pane to navigate to another fragment + * within the pane (landscape) or full-screen (portrait). + * Use this instead of Tools.swapFragment(requireActivity(), ...) from child fragments. + */ + public void openChildPane(Class fragmentClass, String tag, + @Nullable Bundle args) { + openPane(fragmentClass, tag, args); + } + + /** + * Returns true if the pane was used. + */ + public boolean tryOpenInRightPane(Class fragmentClass, String tag, + @Nullable Bundle args) { + if (!isTwoPane()) return false; + openPane(fragmentClass, tag, args); + return true; + } + + /** + * Internal navigation: right pane in landscape, full-screen swap in portrait. + */ + private void openPane(Class fragmentClass, String tag, + @Nullable Bundle args) { + if (isTwoPane()) { + getChildFragmentManager() + .beginTransaction() + .setReorderingAllowed(true) + .replace(R.id.right_pane_container, fragmentClass, args, tag) + .addToBackStack(tag) + .commit(); + } else { + Tools.swapFragment(requireActivity(), fragmentClass, tag, args); + } + } + + // ─── Lifecycle ─────────────────────────────────────────────────────────── + + public MainMenuFragment() { super(R.layout.fragment_launcher); } + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Create the callback once. Lifecycle owner = this fragment, so it is + // automatically removed when the fragment is DESTROYED (not just view-destroyed). + mRightPaneBackCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + // Guard: only act if view is still alive and back stack has entries + if (mRightPane == null) return; + if (getChildFragmentManager().getBackStackEntryCount() > 0) { + getChildFragmentManager().popBackStackImmediate(); + } + } + }; + requireActivity().getOnBackPressedDispatcher() + .addCallback(this, mRightPaneBackCallback); + + // Only register the back-stack listener once per fragment instance. + // Using a member reference so we can remove it in onDestroyView if needed. + getChildFragmentManager().addOnBackStackChangedListener(mBackStackListener); + } + + /** Keeps a stable reference so we never register it twice. */ + private final androidx.fragment.app.FragmentManager.OnBackStackChangedListener + mBackStackListener = () -> { + mRightPaneBackCallback.setEnabled(isRightPaneActive()); + if (!isTwoPane()) return; + // Show bottom bar ONLY on home (back stack empty). Hide on all other panes + // including instance picker (it has its own back button in the header). + boolean showBar = getChildFragmentManager().getBackStackEntryCount() == 0; + setBottomBarVisible(showBar); + }; + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - Button mNewsButton = view.findViewById(R.id.news_button); - Button mDiscordButton = view.findViewById(R.id.discord_button); + Button mNewsButton = view.findViewById(R.id.news_button); + Button mDiscordButton = view.findViewById(R.id.discord_button); Button mCustomControlButton = view.findViewById(R.id.custom_control_button); - Button mInstallJarButton = view.findViewById(R.id.install_jar_button); - Button mShareLogsButton = view.findViewById(R.id.share_logs_button); - Button mOpenDirectoryButton = view.findViewById(R.id.open_files_button); + Button mInstallJarButton = view.findViewById(R.id.install_jar_button); + Button mShareLogsButton = view.findViewById(R.id.share_logs_button); + Button mManageModsButton = view.findViewById(R.id.open_files_button); + Button mOpenDirectoryButton = view.findViewById(R.id.open_directory_button); + Button mModStoreButton = view.findViewById(R.id.mod_store_button); - ImageButton mEditProfileButton = view.findViewById(R.id.edit_profile_button); - Button mPlayButton = view.findViewById(R.id.play_button); + ImageButton mEditProfileBtn = view.findViewById(R.id.edit_profile_button); + Button mPlayBtn = view.findViewById(R.id.play_button); mVersionSpinner = view.findViewById(R.id.mc_version_spinner); - mNewsButton.setOnClickListener(v -> Tools.openURL(requireActivity(), Tools.URL_HOME)); - mDiscordButton.setOnClickListener(v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite))); - mCustomControlButton.setOnClickListener(v -> startActivity(new Intent(requireContext(), CustomControlsActivity.class))); - mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false)); - mInstallJarButton.setOnLongClickListener(v->{ - runInstallerWithConfirmation(true); - return true; - }); - mEditProfileButton.setOnClickListener(v -> mVersionSpinner.openProfileEditor(requireActivity())); + // Detect two-pane landscape layout + mRightPane = view.findViewById(R.id.right_pane_container); + + // Bottom bar refs + mBottomBarBg = view.findViewById(R.id._background_display_view); + mPlayButton = mPlayBtn; + mEditProfileButton = mEditProfileBtn; + mBottomBar = view.findViewById(R.id.bottom_bar); + + // Load home fragment into right pane. + // Check by fragment presence, not savedInstanceState, so rotation works correctly. + if (isTwoPane()) { + Fragment existing = getChildFragmentManager() + .findFragmentById(R.id.right_pane_container); + if (existing == null) { + getChildFragmentManager() + .beginTransaction() + .setReorderingAllowed(true) + .replace(R.id.right_pane_container, RightPaneHomeFragment.class, null, + RightPaneHomeFragment.TAG) + // NOT added to back stack — home is the base, not a destination + .commit(); + } + } - mPlayButton.setOnClickListener(v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true)); + // ── Sidebar buttons that are hidden in landscape (stubs kept for safety) ── + // Wiki / Discord are moved to RightPaneHomeFragment in landscape; + // they stay in the sidebar on portrait via fragment_launcher.xml (no-land). + if (mNewsButton != null) + mNewsButton.setOnClickListener( + v -> Tools.openURL(requireActivity(), Tools.URL_HOME)); + if (mDiscordButton != null) + mDiscordButton.setOnClickListener( + v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite))); - mShareLogsButton.setOnClickListener((v) -> shareLog(requireContext())); + // Custom controls (always opens as Activity — can't be in the pane) + mCustomControlButton.setOnClickListener(v -> + startActivity(new Intent(requireContext(), CustomControlsActivity.class))); - mOpenDirectoryButton.setOnClickListener((v)-> openPath(v.getContext(), getCurrentProfileDirectory(), false)); + // Mod Store + if (mModStoreButton != null) + mModStoreButton.setOnClickListener(v -> + openPane(ModsSearchFragment.class, ModsSearchFragment.TAG, null)); + // Execute .jar + if (hasOnlineProfile()) { + mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false)); + mInstallJarButton.setOnLongClickListener(v -> { + runInstallerWithConfirmation(true); + return true; + }); + } else { + mInstallJarButton.setOnClickListener( + v -> hasNoOnlineProfileDialog(requireActivity())); + } - mNewsButton.setOnLongClickListener((v)->{ - Tools.swapFragment(requireActivity(), GamepadMapperFragment.class, GamepadMapperFragment.TAG, null); - return true; + // Share logs + if (mShareLogsButton != null) + mShareLogsButton.setOnClickListener(v -> shareLog(requireContext())); + + // Manage Mods + mManageModsButton.setOnClickListener(v -> + openPane(ManageModsFragment.class, ManageModsFragment.TAG, null)); + + // Open game directory + if (mOpenDirectoryButton != null) { + mOpenDirectoryButton.setOnClickListener(v -> { + if (Tools.isDemoProfile(v.getContext())) { + hasNoOnlineProfileDialog(getActivity(), + getString(R.string.demo_unsupported), + getString(R.string.change_account)); + } else if (!hasOnlineProfile()) { + hasNoOnlineProfileDialog(requireActivity()); + } else { + openPath(v.getContext(), getCurrentProfileDirectory(), false); + } + }); + } + + // Edit profile — open in right pane in landscape, full-screen in portrait + mEditProfileBtn.setOnClickListener(v -> { + if (isTwoPane()) { + openPane(net.kdt.pojavlaunch.fragments.ProfileEditorFragment.class, + net.kdt.pojavlaunch.fragments.ProfileEditorFragment.TAG, null); + } else { + mVersionSpinner.openProfileEditor(requireActivity()); + } }); + + // In landscape: tapping the spinner opens the instance picker in the right pane + if (isTwoPane()) { + mVersionSpinner.setOnClickListener(v -> + openPane(InstancePickerFragment.class, InstancePickerFragment.TAG, null)); + } + + // Force correct initial bar state BEFORE registering task listener, + // so the listener's immediate callback doesn't fight an unset visibility. + if (isTwoPane()) { + setBottomBarVisible(getChildFragmentManager().getBackStackEntryCount() == 0); + } + + // Play button visibility during downloads handled by activity's ProgressLayout + + // Play + mPlayBtn.setOnClickListener( + v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true)); + + // Long-press wiki → gamepad mapper (hidden feature) + if (mNewsButton != null) + mNewsButton.setOnLongClickListener(v -> { + Tools.swapFragment(requireActivity(), GamepadMapperFragment.class, + GamepadMapperFragment.TAG, null); + return true; + }); } - private File getCurrentProfileDirectory() { - String currentProfile = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); - if(!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW); - LauncherProfiles.load(); - MinecraftProfile profileObject = LauncherProfiles.mainProfileJson.profiles.get(currentProfile); - if(profileObject == null) return new File(Tools.DIR_GAME_NEW); - return Tools.getGameDirPath(profileObject); + @Override + public void onDestroyView() { + super.onDestroyView(); + mRightPane = null; + mBottomBarBg = null; + mPlayButton = null; + mEditProfileButton = null; + mBottomBar = null; + getChildFragmentManager().removeOnBackStackChangedListener(mBackStackListener); } @Override public void onResume() { super.onResume(); - mVersionSpinner.reloadProfiles(); + if (mVersionSpinner != null) { + mVersionSpinner.post(() -> { + if (mVersionSpinner != null) mVersionSpinner.reloadProfiles(); + }); + } + if (isTwoPane() && mBottomBar != null) { + // Post so this runs after any pending task-listener callbacks + // that might incorrectly hide the bar + final boolean showBar = getChildFragmentManager().getBackStackEntryCount() == 0; + mBottomBar.post(() -> setBottomBarVisible(showBar)); + } + } + + // ─── Private helpers ──────────────────────────────────────────────────── + + private File getCurrentProfileDirectory() { + String currentProfile = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW); + LauncherProfiles.load(); + MinecraftProfile profileObject = + LauncherProfiles.mainProfileJson.profiles.get(currentProfile); + if (profileObject == null) return new File(Tools.DIR_GAME_NEW); + return Tools.getGameDirPath(profileObject); } private void runInstallerWithConfirmation(boolean isCustomArgs) { @@ -94,4 +364,4 @@ private void runInstallerWithConfirmation(boolean isCustomArgs) { else Toast.makeText(requireContext(), R.string.tasks_ongoing, Toast.LENGTH_LONG).show(); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java new file mode 100644 index 0000000000..62a949adbd --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java @@ -0,0 +1,122 @@ +package net.kdt.pojavlaunch.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.fragments.ModsSearchFragment; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.InstalledModAdapter; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; + +import java.io.File; + +public class ManageModsFragment extends Fragment { + + public static final String TAG = "ManageModsFragment"; + + public ManageModsFragment() { + super(R.layout.fragment_manage_mods); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + ImageButton backButton = view.findViewById(R.id.manage_mods_back); + ImageButton addButton = view.findViewById(R.id.manage_mods_add); + TextView title = view.findViewById(R.id.manage_mods_title); + RecyclerView recycler = view.findViewById(R.id.manage_mods_recycler); + View emptyState = view.findViewById(R.id.manage_mods_empty); + + // Back — delegate to the activity which handles both portrait (pop activity stack) + // and landscape two-pane (pop right pane) in one reliable place. + backButton.setOnClickListener(v -> requireActivity().onBackPressed()); + + // Add → open mod store — stay in right pane if we're inside one + addButton.setOnClickListener(v -> + navigateToFragment(ModsSearchFragment.class, ModsSearchFragment.TAG)); + + // Title: "ProfileName - Mods" + String profileName = getCurrentProfileName(); + title.setText(profileName.isEmpty() + ? getString(R.string.mcl_button_manage_mods) + : profileName + " - Mods"); + + // Build mod list + File modsDir = getModsDir(); + InstalledModAdapter adapter = new InstalledModAdapter(modsDir, isEmpty -> { + recycler.setVisibility(isEmpty ? View.GONE : View.VISIBLE); + emptyState.setVisibility(isEmpty ? View.VISIBLE : View.GONE); + }); + + recycler.setLayoutManager(new LinearLayoutManager(requireContext())); + recycler.setAdapter(adapter); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private String getCurrentProfileName() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key == null || key.isEmpty()) return ""; + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile == null) return ""; + return profile.name != null ? profile.name : key; + } catch (Exception e) { + return ""; + } + } + + private File getModsDir() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key != null && !key.isEmpty()) { + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile != null) { + File gameDir = Tools.getGameDirPath(profile); + return new File(gameDir, "mods"); + } + } + } catch (Exception ignored) {} + return new File(Tools.DIR_GAME_NEW, "mods"); + } + + /** Go back — pops the parent's child stack synchronously when inside right pane. */ + private void navigateBack() { + Fragment parent = getParentFragment(); + if (parent != null) { + // Synchronous pop — no race condition with the view lifecycle + parent.getChildFragmentManager().popBackStackImmediate(); + } else { + Tools.removeCurrentFragment(requireActivity()); + } + } + + /** Navigate to a fragment — stays inside the right pane when running as a child fragment. */ + private void navigateToFragment(Class fragmentClass, String tag) { + Fragment parent = getParentFragment(); + if (parent != null) { + parent.getChildFragmentManager() + .beginTransaction() + .setReorderingAllowed(true) + .replace(R.id.right_pane_container, fragmentClass, null, tag) + .addToBackStack(tag) + .commit(); + } else { + Tools.swapFragment(requireActivity(), fragmentClass, tag, null); + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java index b95d07a77c..8eefacbf28 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java @@ -16,6 +16,7 @@ import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; +import net.kdt.pojavlaunch.fragments.MainMenuFragment; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; @@ -106,8 +107,16 @@ public boolean shouldOverrideUrlLoading(WebView view, String url) { // Should be captured by the activity to kill the fragment and get ExtraCore.setValue(ExtraConstants.MICROSOFT_LOGIN_TODO, Uri.parse(url)); Toast.makeText(view.getContext(), "Login started !", Toast.LENGTH_SHORT).show(); - Tools.backToMainMenu(requireActivity()); - + // Navigate back to home — use right pane pop if inside MainMenuFragment + Fragment parent = getParentFragment(); + while (parent != null && !(parent instanceof MainMenuFragment)) { + parent = parent.getParentFragment(); + } + if (parent instanceof MainMenuFragment) { + ((MainMenuFragment) parent).clearRightPane(); + } else { + Tools.backToMainMenu(requireActivity()); + } return true; } @@ -129,4 +138,4 @@ public void onPageFinished(WebView view, String url) {} } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java index 1260cdde6c..7b2952d3be 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java @@ -17,7 +17,6 @@ import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.extra.ExtraCore; -import net.kdt.pojavlaunch.mirrors.DownloadMirror; import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener; import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy; import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; @@ -106,7 +105,7 @@ public boolean onChildClick(ExpandableListView expandableListView, View view, in Object forgeVersion = expandableListView.getExpandableListAdapter().getChild(i, i1); ModloaderListenerProxy taskProxy = new ModloaderListenerProxy(); Runnable downloadTask = createDownloadTask(forgeVersion, taskProxy); - setTaskProxy(taskProxy); + setTaskProxyValue(taskProxy); taskProxy.attachListener(this); mExpandableListView.setEnabled(false); new Thread(downloadTask).start(); @@ -118,7 +117,7 @@ public void onDownloadFinished(File downloadedFile) { Tools.runOnUiThread(()->{ Context context = requireContext(); getTaskProxy().detachListener(); - setTaskProxy(null); + deleteTaskProxy(); mExpandableListView.setEnabled(true); // Read the comment in FabricInstallFragment.onDownloadFinished() to see how this works getParentFragmentManager().popBackStackImmediate(); @@ -131,7 +130,7 @@ public void onDataNotAvailable() { Tools.runOnUiThread(()->{ Context context = requireContext(); getTaskProxy().detachListener(); - setTaskProxy(null); + deleteTaskProxy(); mExpandableListView.setEnabled(true); Tools.dialog(context, context.getString(R.string.global_error), @@ -144,15 +143,18 @@ public void onDownloadError(Exception e) { Tools.runOnUiThread(()->{ Context context = requireContext(); getTaskProxy().detachListener(); - setTaskProxy(null); + deleteTaskProxy(); mExpandableListView.setEnabled(true); Tools.showError(context, e); }); } - private void setTaskProxy(ModloaderListenerProxy proxy) { + private void setTaskProxyValue(ModloaderListenerProxy proxy) { ExtraCore.setValue(mExtraTag, proxy); } + private void deleteTaskProxy(){ + ExtraCore.removeValue(mExtraTag); + } private ModloaderListenerProxy getTaskProxy() { return (ModloaderListenerProxy) ExtraCore.getValue(mExtraTag); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java new file mode 100644 index 0000000000..d595c75fa4 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java @@ -0,0 +1,59 @@ +package net.kdt.pojavlaunch.fragments; + +import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; +import static net.kdt.pojavlaunch.Tools.hasOnlineProfile; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.kdt.mcgui.MineButton; + +import net.kdt.pojavlaunch.BaseActivity; +import net.kdt.pojavlaunch.LauncherActivity; +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.contracts.OpenDocumentWithExtension; +import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; + +public class ModpackCreateFragment extends Fragment { + public static final String TAG = "ModpackCreateFragment"; + public ModpackCreateFragment() { + super(R.layout.fragment_create_modpack_profile); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.button_browse_modpacks).setOnClickListener(v -> { + tryInstall(SearchModFragment.class, SearchModFragment.TAG); + }); + view.findViewById(R.id.button_import_modpack).setOnClickListener(v -> { + Activity launcheractivity = requireActivity(); + if (!(launcheractivity instanceof LauncherActivity)) + throw new IllegalStateException("Cannot import modpack without LauncherActivity"); + ((LauncherActivity) launcheractivity).modpackImportLauncher.launch(null); + });; + } + + private void tryInstall(Class fragmentClass, String tag){ + if(!hasOnlineProfile()){ + hasNoOnlineProfileDialog(requireActivity()); + } else { + // Walk up to find MainMenuFragment + Fragment parent = getParentFragment(); + while (parent != null && !(parent instanceof MainMenuFragment)) { + parent = parent.getParentFragment(); + } + if (parent instanceof MainMenuFragment) { + ((MainMenuFragment) parent).openChildPane(fragmentClass, tag, null); + } else { + Tools.swapFragment(requireActivity(), fragmentClass, tag, null); + } + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java new file mode 100644 index 0000000000..f7b86d8538 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java @@ -0,0 +1,449 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.Toast; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.math.MathUtils; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter; +import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; +import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; +import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; +import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Searches and installs individual mods into the current instance's mods folder. + * - Version filter: when an MC version is selected, only versions matching it are shown. + * - Dependency dialog: shown before download, matching the ModBundle UI. + */ +public class ModsSearchFragment extends Fragment implements ModItemAdapter.SearchResultCallback { + + public static final String TAG = "ModsSearchFragment"; + + private View mOverlay; + private float mOverlayTopCache; + + private final RecyclerView.OnScrollListener mOverlayPositionListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + mOverlay.setY(MathUtils.clamp(mOverlay.getY() - dy, -mOverlay.getHeight(), mOverlayTopCache)); + } + }; + + private EditText mSearchEditText; + private ImageButton mFilterButton; + private RecyclerView mRecyclerview; + private ModItemAdapter mModItemAdapter; + private ProgressBar mSearchProgressBar; + private TextView mStatusTextView; + private ColorStateList mDefaultTextColor; + + private ModpackApi mModpackApi; + private final SearchFilters mSearchFilters; + + public ModsSearchFragment() { + super(R.layout.fragment_mod_search); + mSearchFilters = new SearchFilters(); + mSearchFilters.isModpack = false; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key), mSearchFilters); + ((ModsInstallApi) mModpackApi).mActivityContext = context; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mModItemAdapter = new ModItemAdapter(getResources(), mModpackApi, this); + ProgressKeeper.addTaskCountListener(mModItemAdapter); + mOverlayTopCache = getResources().getDimension(R.dimen.fragment_padding_medium); + + mOverlay = view.findViewById(R.id.search_mod_overlay); + mSearchEditText = view.findViewById(R.id.search_mod_edittext); + mSearchProgressBar = view.findViewById(R.id.search_mod_progressbar); + mRecyclerview = view.findViewById(R.id.search_mod_list); + mStatusTextView = view.findViewById(R.id.search_mod_status_text); + mFilterButton = view.findViewById(R.id.search_mod_filter); + + mDefaultTextColor = mStatusTextView.getTextColors(); + + mRecyclerview.setLayoutManager(new LinearLayoutManager(getContext())); + mRecyclerview.setAdapter(mModItemAdapter); + mRecyclerview.addOnScrollListener(mOverlayPositionListener); + + mSearchEditText.setOnEditorActionListener((v, actionId, event) -> { + searchMods(mSearchEditText.getText().toString()); + mSearchEditText.clearFocus(); + return false; + }); + + mOverlay.post(() -> { + int overlayHeight = mOverlay.getHeight(); + mRecyclerview.setPadding( + mRecyclerview.getPaddingLeft(), + mRecyclerview.getPaddingTop() + overlayHeight, + mRecyclerview.getPaddingRight(), + mRecyclerview.getPaddingBottom()); + }); + + mFilterButton.setOnClickListener(v -> displayFilterDialog()); + mSearchEditText.setHint(R.string.hint_search_mod); + searchMods(null); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + ProgressKeeper.removeTaskCountListener(mModItemAdapter); + mRecyclerview.removeOnScrollListener(mOverlayPositionListener); + } + + @Override + public void onSearchFinished() { + mSearchProgressBar.setVisibility(View.GONE); + mStatusTextView.setVisibility(View.GONE); + } + + @Override + public void onSearchError(int error) { + mSearchProgressBar.setVisibility(View.GONE); + mStatusTextView.setVisibility(View.VISIBLE); + switch (error) { + case ERROR_INTERNAL: + mStatusTextView.setTextColor(Color.RED); + mStatusTextView.setText(R.string.search_mod_error); + break; + case ERROR_NO_RESULTS: + mStatusTextView.setTextColor(mDefaultTextColor); + mStatusTextView.setText(R.string.search_mod_no_result); + break; + } + } + + private void searchMods(String name) { + mSearchProgressBar.setVisibility(View.VISIBLE); + mSearchFilters.name = name == null ? "" : name; + mModItemAdapter.performSearchQuery(mSearchFilters); + } + + private void displayFilterDialog() { + AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setView(R.layout.dialog_mod_filters) + .create(); + + dialog.setOnShowListener(dialogInterface -> { + TextView mSelectedVersion = dialog.findViewById(R.id.search_mod_selected_mc_version_textview); + Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button); + Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters); + android.widget.Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); + + assert mSelectedVersion != null; + assert mSelectVersionButton != null; + assert mApplyButton != null; + + // Set up loader spinner + if (mLoaderSpinner != null) { + String[] loaderLabels = {"Any loader", "Fabric", "Forge", "Quilt", "NeoForge"}; + final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; + android.widget.ArrayAdapter loaderAdapter = new android.widget.ArrayAdapter<>( + requireContext(), android.R.layout.simple_spinner_item, loaderLabels); + loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mLoaderSpinner.setAdapter(loaderAdapter); + + // Restore current selection + String currentLoader = mSearchFilters.modLoader != null ? mSearchFilters.modLoader : ""; + for (int i = 0; i < loaderValues.length; i++) { + if (loaderValues[i].equals(currentLoader)) { + mLoaderSpinner.setSelection(i); + break; + } + } + + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + + mSelectedVersion.setText(mSearchFilters.mcVersion); + + mApplyButton.setOnClickListener(v -> { + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + int pos = mLoaderSpinner.getSelectedItemPosition(); + mSearchFilters.modLoader = loaderValues[pos]; + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); + } else { + // Fallback if spinner view not found + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + + mSelectedVersion.setText(mSearchFilters.mcVersion); + + mApplyButton.setOnClickListener(v -> { + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); + } + }); + + dialog.show(); + } + + // ── ModsInstallApi ──────────────────────────────────────────────────────── + + private static class ModsInstallApi extends CommonApi { + + private final SearchFilters mFilters; + private final ModrinthApi mModrinthApi = new ModrinthApi(); + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); + private Context mActivityContext; + private final net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi mCurseforgeApi; + + ModsInstallApi(String curseforgeApiKey, SearchFilters filters) { + super(curseforgeApiKey); + mFilters = filters; + mCurseforgeApi = new net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi(curseforgeApiKey); + } + + /** + * Override getModDetails to filter versions by the selected MC version. + * Only versions matching the filter are shown in the version dropdown. + */ + @Override + public ModDetail getModDetails(ModItem item) { + if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH) { + String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) + ? mFilters.mcVersion : null; + String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty()) + ? mFilters.modLoader : null; + return mModrinthApi.getModDetails(item, filterVer, filterLoader); + } + if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_CURSEFORGE) { + String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) + ? mFilters.mcVersion : null; + return mCurseforgeApi.getModDetails(item, filterVer); + } + return super.getModDetails(item); + } + + @Override + public void handleInstallation(Context context, ModDetail modDetail, int selectedVersion) { + if (modDetail.isModpack) { + super.handleInstallation(context, modDetail, selectedVersion); + return; + } + + String url = modDetail.versionUrls[selectedVersion]; + + // Check if this is a CF-restricted mod using the flag set during search + boolean isCfRestricted = modDetail.apiSource == Constants.SOURCE_CURSEFORGE + && (modDetail.isRestricted || url == null || url.isEmpty()); + + if (isCfRestricted) { + String cfUrl = (modDetail.websiteUrl != null && !modDetail.websiteUrl.isEmpty()) + ? modDetail.websiteUrl + : "https://www.curseforge.com/minecraft/mc-mods/" + modDetail.id; + Context dialogCtx = mActivityContext != null ? mActivityContext : context; + mMainHandler.post(() -> + new AlertDialog.Builder(dialogCtx) + .setTitle(modDetail.title) + .setMessage("This mod restricts third-party downloads.\n\nDownload it manually from CurseForge and place it in your mods folder:\n\n" + cfUrl) + .setPositiveButton("Open CurseForge", (d, w) -> + Tools.openURL((android.app.Activity) dialogCtx, cfUrl)) + .setNegativeButton(android.R.string.cancel, null) + .show() + ); + return; + } + + if (url == null || url.isEmpty()) { + Tools.showErrorRemote(context, R.string.modpack_install_download_failed, + new IOException("No download URL available for this mod")); + return; + } + + // Extract filename + String rawName = url.substring(url.lastIndexOf('/') + 1); + if (rawName.contains("?")) rawName = rawName.substring(0, rawName.indexOf('?')); + final String fileName = rawName.endsWith(".jar") ? rawName : rawName + ".jar"; + + // Check if this version has dependencies + String[] depIds = (modDetail.versionDependencyIds != null) ? modDetail.versionDependencyIds[selectedVersion] : null; + String[] depTypes = (modDetail.versionDependencyTypes != null) ? modDetail.versionDependencyTypes[selectedVersion] : null; + + if (depIds == null || depIds.length == 0) { + // No deps — download directly + downloadMod(context, url, fileName, new String[0], new String[0]); + return; + } + + // Fetch project names for all deps, then show dialog + String[] labels = new String[depIds.length]; + final boolean[] checkedDefaults = new boolean[depIds.length]; + AtomicInteger remaining = new AtomicInteger(depIds.length); + + for (int i = 0; i < depIds.length; i++) { + final int idx = i; + final String type = (depTypes != null && idx < depTypes.length) ? depTypes[idx] : "required"; + final String prefix = "required".equals(type) ? "Required: " : "Optional: "; + checkedDefaults[idx] = "required".equals(type); + + final String projectId = depIds[idx]; + PojavApplication.sExecutorService.execute(() -> { + // Fetch project name from Modrinth + String name = fetchProjectName(projectId); + labels[idx] = prefix + (name != null ? name : projectId); + if (remaining.decrementAndGet() == 0) { + mMainHandler.post(() -> showDepsDialog(context, url, fileName, + depIds, depTypes, labels, checkedDefaults)); + } + }); + } + } + + private void showDepsDialog(Context context, String url, String fileName, + String[] depIds, String[] depTypes, + String[] labels, boolean[] checkedDefaults) { + // context here is getApplicationContext() from ModItemAdapter — no window token. + // Use the stored Activity reference instead. + Context dialogCtx = mActivityContext != null ? mActivityContext : context; + boolean[] selected = checkedDefaults.clone(); + + new AlertDialog.Builder(dialogCtx) + .setTitle(R.string.mod_deps_title) + .setMultiChoiceItems(labels, selected, + (dialog, which, isChecked) -> selected[which] = isChecked) + .setPositiveButton(R.string.mod_deps_install_selected, (d, w) -> { + List selectedIds = new ArrayList<>(); + for (int i = 0; i < depIds.length; i++) { + if (selected[i]) selectedIds.add(depIds[i]); + } + downloadMod(context, url, fileName, + selectedIds.toArray(new String[0]), depTypes); + }) + .setNeutralButton(R.string.mod_deps_install_without, + (d, w) -> downloadMod(context, url, fileName, new String[0], new String[0])) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void downloadMod(Context context, String url, String fileName, + String[] depIds, String[] depTypes) { + File modsDir = getModsDir(); + if (!modsDir.exists()) modsDir.mkdirs(); + + ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting); + PojavApplication.sExecutorService.execute(() -> { + try { + // Download main mod + DownloadUtils.downloadFile(url, new File(modsDir, fileName)); + + // Download selected dependencies + for (String depId : depIds) { + if (depId == null || depId.isEmpty()) continue; + downloadDependency(depId, modsDir); + } + + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + Tools.runOnUiThread(() -> + Toast.makeText(context, + context.getString(R.string.mod_install_success, fileName), + Toast.LENGTH_LONG).show()); + } catch (Exception e) { + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + Tools.showErrorRemote(context, R.string.modpack_install_download_failed, e); + } + }); + } + + private void downloadDependency(String projectId, File modsDir) { + // Fetch latest version for the current MC version/loader filter + try { + String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) + ? mFilters.mcVersion : ""; + String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty()) + ? mFilters.modLoader : null; + ModItem depItem = new ModItem( + net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH, + false, projectId, projectId, "", ""); + ModDetail depDetail = mModrinthApi.getModDetails(depItem, filterVer.isEmpty() ? null : filterVer, filterLoader); + if (depDetail == null || depDetail.versionUrls == null || depDetail.versionUrls.length == 0) return; + + String depUrl = depDetail.versionUrls[0]; + String depName = depUrl.substring(depUrl.lastIndexOf('/') + 1); + if (depName.contains("?")) depName = depName.substring(0, depName.indexOf('?')); + if (!depName.endsWith(".jar")) depName += ".jar"; + + DownloadUtils.downloadFile(depUrl, new File(modsDir, depName)); + } catch (Exception e) { + Log.w(TAG, "Failed to download dependency " + projectId + ": " + e.getMessage()); + } + } + + private String fetchProjectName(String projectId) { + try { + net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler handler = + new net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler("https://api.modrinth.com/v2"); + com.google.gson.JsonObject obj = handler.get("project/" + projectId, + com.google.gson.JsonObject.class); + if (obj != null && obj.has("title")) return obj.get("title").getAsString(); + } catch (Exception ignored) {} + return null; + } + + private static File getModsDir() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key != null && !key.isEmpty()) { + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile != null) return new File(Tools.getGameDirPath(profile), "mods"); + } + } catch (Exception ignored) {} + return new File(Tools.DIR_GAME_NEW, "mods"); + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java new file mode 100644 index 0000000000..51fb7bd7d0 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java @@ -0,0 +1,115 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.content.Intent; +import android.view.LayoutInflater; +import android.widget.ExpandableListAdapter; + +import androidx.annotation.NonNull; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.kdt.pojavlaunch.JavaGUILauncherActivity; +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.ForgeDownloadTask; +import net.kdt.pojavlaunch.modloaders.ForgeUtils; +import net.kdt.pojavlaunch.modloaders.ForgeVersionListHandler; +import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy; +import net.kdt.pojavlaunch.modloaders.NeoForgeDownloadTask; +import net.kdt.pojavlaunch.modloaders.NeoForgeVersionListAdapter; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.util.List; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +public class NeoForgeInstallFragment extends ModVersionListFragment> { + public static final String TAG = "NeoForgeInstallFragment"; + public NeoForgeInstallFragment() { + super(TAG); + } + + private static final String NEOFORGE_METADATA_URL = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"; + + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + } + + @Override + public int getTitleText() { + return R.string.neoforge_dl_select_version; + } + + @Override + public int getNoDataMsg() { + return R.string.neoforge_dl_no_installer; + } + + @Override + public List loadVersionList() { + return downloadNeoForgeVersions(); + } + + public static List downloadNeoForgeVersions() { + SAXParser saxParser; + try { + SAXParserFactory parserFactory = SAXParserFactory.newInstance(); + saxParser = parserFactory.newSAXParser(); + }catch (SAXException | ParserConfigurationException e) { + e.printStackTrace(); + // if we cant make a parser we might as well not even try to parse anything + return null; + } + try { + //of_test(); + return DownloadUtils.downloadStringCached(NEOFORGE_METADATA_URL, "neoforge_versions", input -> { + try { + ForgeVersionListHandler handler = new ForgeVersionListHandler(); + saxParser.parse(new InputSource(new StringReader(input)), handler); + return handler.getVersions(); + // IOException is present here StringReader throws it only if the parser called close() + // sooner than needed, which is a parser issue and not an I/O one + }catch (SAXException | IOException e) { + throw new DownloadUtils.ParseException(e); + } + }); + }catch (DownloadUtils.ParseException | IOException e) { + e.printStackTrace(); + return null; + } + } + + + @Override + public ExpandableListAdapter createAdapter(List versionList, LayoutInflater layoutInflater) { + return new NeoForgeVersionListAdapter(versionList, layoutInflater); + } + + @Override + public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) { + return new NeoForgeDownloadTask(listenerProxy, (String) selectedVersion); + } + + @Override + public void onDownloadFinished(Context context, File downloadedFile) { + Intent modInstallerStartIntent = new Intent(context, JavaGUILauncherActivity.class); + modInstallerStartIntent + .putExtra("javaArgs", "-jar "+downloadedFile.getAbsolutePath()+" --install-client") + .putExtra("openLogOutput", true); + context.startActivity(modInstallerStartIntent); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java new file mode 100644 index 0000000000..40490e616e --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java @@ -0,0 +1,31 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.kdt.pojavlaunch.modloaders.ForgelikeUtils; + +import git.artdeell.mojo.R; + +public class NeoforgeInstallFragment extends ForgelikeInstallFragment { + public static final String TAG = "NeoforgeInstallFragment"; + public NeoforgeInstallFragment() { + super(ForgelikeUtils.NEOFORGE_UTILS, TAG); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + } + + @Override + public int getTitleText() { + return R.string.neoforge_dl_select_version; + } + + @Override + public int getNoDataMsg() { + return R.string.neoforge_dl_no_installer; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java new file mode 100644 index 0000000000..4a3a112bd3 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java @@ -0,0 +1,55 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.net.Uri; +import android.widget.Toast; + +import androidx.fragment.app.FragmentActivity; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.extra.ExtraCore; + +import git.artdeell.mojo.R; + +public class OAuthFragment extends WebViewCompletionFragment { + + private static final String QUERY_ERROR_NAME = "error"; + private static final String QUERY_ERROR_DECRIPTION = "error_description"; + private static final String QUERY_OAUTH_CODE = "code"; + private static final String ERROR_ACCESS_DENIED = "access_denied"; + + private final String mExtraCoreConstant; + + protected OAuthFragment(String mTrackedUrl, String mAuthUrl, String mExtraCoreConstant) { + super(mTrackedUrl, mAuthUrl); + this.mExtraCoreConstant = mExtraCoreConstant; + } + + private void displayError(Context context, Uri uri) { + String errorMessage = uri.getQueryParameter(QUERY_ERROR_DECRIPTION); + if(errorMessage == null) errorMessage = uri.getQueryParameter(QUERY_ERROR_NAME); + if(errorMessage == null) errorMessage = getString(R.string.oauth_unknown_error); + Tools.dialog(context, getString(R.string.global_error), errorMessage); + } + + @Override + protected void signalCompletion(String fullUrl) { + FragmentActivity activity = getActivity(); + if(activity == null) return; + Uri uri = Uri.parse(fullUrl); + String error = uri.getQueryParameter(QUERY_ERROR_NAME); + String code = uri.getQueryParameter(QUERY_OAUTH_CODE); + if(code == null) { + activity.onBackPressed(); + // Access denied - means the user exited out of the oauth dialog. Just leave the fragment + if(ERROR_ACCESS_DENIED.equals(error)) return; + // On other unknown errors, show a dialog + displayError(activity, uri); + return; + } + // Captured by the listener in the mcAccountSpinner + ExtraCore.setValue(mExtraCoreConstant, code); + Toast.makeText(activity, R.string.oauth_web_complete, Toast.LENGTH_SHORT).show(); + Tools.backToMainMenu(activity); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java index 8b5a32b156..a48bc94c80 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java @@ -41,7 +41,7 @@ public ExpandableListAdapter createAdapter(OptiFineUtils.OptiFineVersions versio @Override public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) { - return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion, listenerProxy); + return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion, listenerProxy, requireActivity()); } @Override diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java index 501b0147a7..bec95e7007 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java @@ -43,6 +43,8 @@ import java.util.Arrays; import java.util.List; +import androidx.fragment.app.Fragment; + public class ProfileEditorFragment extends Fragment implements CropperUtils.CropperListener{ public static final String TAG = "ProfileEditorFragment"; public static final String DELETED_PROFILE = "deleted_profile"; @@ -93,7 +95,14 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat mSaveButton.setOnClickListener(v -> { ProfileIconCache.dropIcon(mProfileKey); save(); - Tools.backToMainMenu(requireActivity()); + Fragment parentFrag = getParentFragment(); + if (parentFrag instanceof MainMenuFragment) { + MainMenuFragment mmf = (MainMenuFragment) parentFrag; + mmf.clearRightPane(); + mmf.reloadSpinner(); + } else { + Tools.backToMainMenu(requireActivity()); + } }); mDeleteButton.setOnClickListener(v -> { @@ -101,10 +110,17 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat ProfileIconCache.dropIcon(mProfileKey); LauncherProfiles.mainProfileJson.profiles.remove(mProfileKey); LauncherProfiles.write(); - ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, DELETED_PROFILE); + ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, ProfileEditorFragment.DELETED_PROFILE); + } + Fragment parentFrag = getParentFragment(); + if (parentFrag instanceof MainMenuFragment) { + MainMenuFragment mmf = (MainMenuFragment) parentFrag; + mmf.clearRightPane(); + // Reload spinner now so deleted profile is gone immediately + mmf.reloadSpinner(); + } else { + Tools.removeCurrentFragment(requireActivity()); } - - Tools.removeCurrentFragment(requireActivity()); }); @@ -164,12 +180,15 @@ private void loadValues(@NonNull String profile, @NonNull Context context){ if(mTempProfile == null){ mTempProfile = getProfile(profile); } + // TODO: Remove this jank when it's not relevant anymore + // Shitty hack to make OSMZink smoothly transition into kopper + if ("vulkan_zink".equals(mTempProfile.pojavRendererName)) mTempProfile.pojavRendererName = "opengles3_desktopgl_zink_kopper"; mProfileIcon.setImageDrawable( ProfileIconCache.fetchIcon(getResources(), mProfileKey, mTempProfile.icon) ); // Runtime spinner - List runtimes = MultiRTUtils.getRuntimes(); + List runtimes = MultiRTUtils.getInstalledRuntimes(); int jvmIndex = runtimes.indexOf(new Runtime("")); if (mTempProfile.javaDir != null) { String selectedRuntime = mTempProfile.javaDir.substring(Tools.LAUNCHERPROFILES_RTPREFIX.length()); @@ -242,7 +261,9 @@ private void save(){ mTempProfile.lastVersionId = mDefaultVersion.getText().toString(); mTempProfile.controlFile = mDefaultControl.getText().toString(); mTempProfile.name = mDefaultName.getText().toString(); - mTempProfile.javaArgs = mDefaultJvmArgument.getText().toString(); + mTempProfile.javaArgs = mDefaultJvmArgument.getText().toString() + .replaceAll("[\r\n]+", " ") + .trim(); mTempProfile.gameDir = mDefaultPath.getText().toString(); if(mTempProfile.controlFile.isEmpty()) mTempProfile.controlFile = null; @@ -262,6 +283,22 @@ private void save(){ ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, mProfileKey); } + @Override + public void onDestroyView() { + super.onDestroyView(); + // Always drop the icon cache when leaving, even without saving, + // so the next reloadProfiles() re-fetches with correct bounds. + if (mProfileKey != null) { + ProfileIconCache.dropIcon(mProfileKey); + // Reload the spinner so the icon redraws at correct size immediately + // (covers Android back button path where reloadSpinner() isn't called explicitly) + Fragment parent = getParentFragment(); + if (parent instanceof MainMenuFragment) { + ((MainMenuFragment) parent).reloadSpinner(); + } + } + } + @Override public void onCropped(Bitmap contentBitmap) { mProfileIcon.setImageBitmap(contentBitmap); @@ -294,4 +331,4 @@ public void onCropped(Bitmap contentBitmap) { public void onFailed(Exception exception) { Tools.showErrorRemote(exception); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java index 9e409a41e1..60124891df 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java @@ -1,12 +1,17 @@ package net.kdt.pojavlaunch.fragments; +import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; +import static net.kdt.pojavlaunch.Tools.hasOnlineProfile; + import android.os.Bundle; import android.view.View; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import net.kdt.pojavlaunch.PojavProfile; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; @@ -15,27 +20,51 @@ public class ProfileTypeSelectFragment extends Fragment { public ProfileTypeSelectFragment() { super(R.layout.fragment_profile_type); } + public ProfileTypeSelectFragment(int layout) { + super(layout); + } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - view.findViewById(R.id.vanilla_profile).setOnClickListener(v -> Tools.swapFragment(requireActivity(), ProfileEditorFragment.class, - ProfileEditorFragment.TAG, new Bundle(1))); - - // NOTE: Special care needed! If you wll decide to add these to the back stack, please read - // the comment in FabricInstallFragment.onDownloadFinished() and amend the code - // in FabricInstallFragment.onDownloadFinished() and ModVersionListFragment.onDownloadFinished() - view.findViewById(R.id.optifine_profile).setOnClickListener(v -> Tools.swapFragment(requireActivity(), OptiFineInstallFragment.class, - OptiFineInstallFragment.TAG, null)); + view.findViewById(R.id.vanilla_profile).setOnClickListener(v -> + navigateTo(ProfileEditorFragment.class, ProfileEditorFragment.TAG, new Bundle(1))); + + view.findViewById(R.id.optifine_profile).setOnClickListener(v -> + tryInstall(OptiFineInstallFragment.class, OptiFineInstallFragment.TAG)); view.findViewById(R.id.modded_profile_fabric).setOnClickListener((v)-> - Tools.swapFragment(requireActivity(), FabricInstallFragment.class, FabricInstallFragment.TAG, null)); + tryInstall(FabricInstallFragment.class, FabricInstallFragment.TAG)); view.findViewById(R.id.modded_profile_forge).setOnClickListener((v)-> - Tools.swapFragment(requireActivity(), ForgeInstallFragment.class, ForgeInstallFragment.TAG, null)); + tryInstall(ForgeInstallFragment.class, ForgeInstallFragment.TAG)); + view.findViewById(R.id.modded_profile_neoforge).setOnClickListener((v)-> + tryInstall(NeoForgeInstallFragment.class, NeoForgeInstallFragment.TAG)); view.findViewById(R.id.modded_profile_modpack).setOnClickListener((v)-> - Tools.swapFragment(requireActivity(), SearchModFragment.class, SearchModFragment.TAG, null)); + tryInstall(ModpackCreateFragment.class, ModpackCreateFragment.TAG)); view.findViewById(R.id.modded_profile_quilt).setOnClickListener((v)-> - Tools.swapFragment(requireActivity(), QuiltInstallFragment.class, QuiltInstallFragment.TAG, null)); + tryInstall(QuiltInstallFragment.class, QuiltInstallFragment.TAG)); view.findViewById(R.id.modded_profile_bta).setOnClickListener((v)-> - Tools.swapFragment(requireActivity(), BTAInstallFragment.class, BTAInstallFragment.TAG, null)); + tryInstall(BTAInstallFragment.class, BTAInstallFragment.TAG)); + } + + /** Navigate within right pane if inside MainMenuFragment, otherwise full-screen swap. */ + protected void navigateTo(Class cls, String tag, android.os.Bundle args) { + // Walk up to find MainMenuFragment (could be grandparent if nested) + Fragment parent = getParentFragment(); + while (parent != null && !(parent instanceof MainMenuFragment)) { + parent = parent.getParentFragment(); + } + if (parent instanceof MainMenuFragment) { + ((MainMenuFragment) parent).openChildPane(cls, tag, args); + } else { + Tools.swapFragment(requireActivity(), cls, tag, args); + } + } + + private void tryInstall(Class fragmentClass, String tag){ + if(!hasOnlineProfile()){ + hasNoOnlineProfileDialog(requireActivity()); + } else { + navigateTo(fragmentClass, tag, null); + } } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java new file mode 100644 index 0000000000..cceea28ac0 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java @@ -0,0 +1,79 @@ +package net.kdt.pojavlaunch.fragments; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; + +import java.io.File; + +/** + * Default content of the right pane in landscape two-pane mode. + * Shows a custom background (if set), otherwise a plain transparent pane. + * Wiki and Discord buttons are pinned at the top. + */ +public class RightPaneHomeFragment extends Fragment { + + public static final String TAG = "RightPaneHomeFragment"; + /** File path where the custom launcher background image is stored. */ + public static final String CUSTOM_BG_PATH = Tools.DIR_DATA + "/custom_launcher_bg"; + + public RightPaneHomeFragment() { + super(R.layout.fragment_right_pane_home); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.news_button_pane).setOnClickListener( + v -> Tools.openURL(requireActivity(), Tools.URL_HOME)); + + view.findViewById(R.id.discord_button_pane).setOnClickListener( + v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite))); + + loadBackground(view); + } + + /** + * Called after saving or removing a custom background so the pane + * refreshes without needing a full fragment recreate. + */ + public void reloadBackground() { + View v = getView(); + if (v != null) loadBackground(v); + } + + private void loadBackground(@NonNull View view) { + ImageView wallpaper = view.findViewById(R.id.right_pane_wallpaper); + File bgFile = new File(CUSTOM_BG_PATH); + if (bgFile.exists()) { + Drawable d = Drawable.createFromPath(bgFile.getAbsolutePath()); + if (d != null) { + wallpaper.setImageDrawable(d); + wallpaper.setScaleType(ImageView.ScaleType.CENTER_CROP); + wallpaper.setBackground(null); + wallpaper.setVisibility(View.VISIBLE); + return; + } + } + // No custom bg — show the gradient drawable as the pane background if gradient is on, + // otherwise stay transparent (root fragment_launcher bg shows through). + wallpaper.setImageDrawable(null); + TypedValue tv = new TypedValue(); + view.getContext().getTheme().resolveAttribute(R.attr.bgMainDrawable, tv, true); + if (tv.resourceId != 0) { + wallpaper.setBackgroundResource(tv.resourceId); + wallpaper.setVisibility(View.VISIBLE); + } else { + wallpaper.setBackground(null); + wallpaper.setVisibility(View.GONE); + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java index e2b8ddc039..59fb74d8b9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java @@ -5,10 +5,12 @@ import android.graphics.Color; import android.os.Bundle; import android.view.View; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ProgressBar; +import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.NonNull; @@ -23,6 +25,10 @@ import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter; import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi; import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi; +import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; @@ -61,7 +67,7 @@ public SearchModFragment(){ @Override public void onAttach(@NonNull Context context) { super.onAttach(context); - modpackApi = new CommonApi(context.getString(R.string.curseforge_api_key)); + modpackApi = new ModpackSearchApi(context.getString(R.string.curseforge_api_key), mSearchFilters); } @Override @@ -148,26 +154,85 @@ private void displayFilterDialog() { TextView mSelectedVersion = dialog.findViewById(R.id.search_mod_selected_mc_version_textview); Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button); Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters); + Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); assert mSelectVersionButton != null; assert mSelectedVersion != null; assert mApplyButton != null; - // Setup the expendable list behavior - mSelectVersionButton.setOnClickListener(v -> VersionSelectorDialog.open(v.getContext(), true, (id, snapshot)-> mSelectedVersion.setText(id))); + // Set up loader spinner + if (mLoaderSpinner != null) { + String[] loaderLabels = {"Any loader", "Fabric", "Forge", "Quilt", "NeoForge"}; + final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; + ArrayAdapter loaderAdapter = new ArrayAdapter<>( + requireContext(), android.R.layout.simple_spinner_item, loaderLabels); + loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mLoaderSpinner.setAdapter(loaderAdapter); + + // Restore current selection + String currentLoader = mSearchFilters.modLoader != null ? mSearchFilters.modLoader : ""; + for (int i = 0; i < loaderValues.length; i++) { + if (loaderValues[i].equals(currentLoader)) { + mLoaderSpinner.setSelection(i); + break; + } + } + + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + + mSelectedVersion.setText(mSearchFilters.mcVersion); + + mApplyButton.setOnClickListener(v -> { + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + int pos = mLoaderSpinner.getSelectedItemPosition(); + mSearchFilters.modLoader = loaderValues[pos]; + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); + } else { + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + mSelectedVersion.setText(mSearchFilters.mcVersion); + mApplyButton.setOnClickListener(v -> { + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); + } + }); - // Apply visually all the current settings - mSelectedVersion.setText(mSearchFilters.mcVersion); + dialog.show(); + } - // Apply the new settings - mApplyButton.setOnClickListener(v -> { - mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); - searchMods(mSearchEditText.getText().toString()); - dialogInterface.dismiss(); - }); - }); + // ── ModpackSearchApi ────────────────────────────────────────────────────── + private static class ModpackSearchApi extends CommonApi { + private final SearchFilters mFilters; + private final ModrinthApi mModrinthApi = new ModrinthApi(); - dialog.show(); + ModpackSearchApi(String curseforgeApiKey, SearchFilters filters) { + super(curseforgeApiKey); + mFilters = filters; + } + + /** + * Override getModDetails so the version dropdown only shows versions + * matching the selected MC version and loader filter. + */ + @Override + public ModDetail getModDetails(ModItem item) { + if (item.apiSource == Constants.SOURCE_MODRINTH) { + String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) + ? mFilters.mcVersion : null; + String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty()) + ? mFilters.modLoader : null; + return mModrinthApi.getModDetails(item, filterVer, filterLoader); + } + // CurseForge: delegate normally (CF search already filters by version/loader) + return super.getModDetails(item); + } } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java index 7814089885..5790dea758 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch.fragments; +import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; + import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -23,7 +25,18 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat Button mMicrosoftButton = view.findViewById(R.id.button_microsoft_authentication); Button mLocalButton = view.findViewById(R.id.button_local_authentication); - mMicrosoftButton.setOnClickListener(v -> Tools.swapFragment(requireActivity(), MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG, null)); - mLocalButton.setOnClickListener(v -> Tools.swapFragment(requireActivity(), LocalLoginFragment.class, LocalLoginFragment.TAG, null)); + mMicrosoftButton.setOnClickListener(v -> navigateTo(MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG, null)); + mLocalButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity(), + () -> navigateTo(LocalLoginFragment.class, LocalLoginFragment.TAG, null))); + } + + /** Navigate within right pane if inside MainMenuFragment, otherwise full-screen swap. */ + private void navigateTo(Class cls, String tag, android.os.Bundle args) { + Fragment parent = getParentFragment(); + if (parent instanceof MainMenuFragment) { + ((MainMenuFragment) parent).openChildPane(cls, tag, args); + } else { + Tools.swapFragment(requireActivity(), cls, tag, args); + } } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java new file mode 100644 index 0000000000..b29762d2da --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java @@ -0,0 +1,129 @@ +package net.kdt.pojavlaunch.fragments; + +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import git.artdeell.mojo.R; + +public abstract class WebViewCompletionFragment extends Fragment { + private final String mTrackedUrl; + private final String mAuthUrl; + private WebView mWebview; + // Technically the client is blank (or there is none) when the fragment is initialized + private boolean mBlankClient = true; + private boolean mIsCompleted = false; + + protected WebViewCompletionFragment(String mTrackedUrl, String mAuthUrl) { + this.mTrackedUrl = mTrackedUrl; + this.mAuthUrl = mAuthUrl; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + mWebview = (WebView) inflater.inflate(R.layout.fragment_microsoft_login, container, false); + setWebViewSettings(); + if(savedInstanceState == null) startNewSession(); + else restoreWebViewState(savedInstanceState); + return mWebview; + } + + // WebView.restoreState() does not restore the WebSettings or the client, so set them there + // separately. Note that general state should not be altered here (aka no loading pages, no manipulating back/front lists), + // to avoid "undesirable side-effects" + @SuppressLint("SetJavaScriptEnabled") + private void setWebViewSettings() { + WebSettings settings = mWebview.getSettings(); + settings.setJavaScriptEnabled(true); + mWebview.setWebViewClient(new WebViewTrackClient()); + mBlankClient = false; + } + + private void startNewSession() { + CookieManager.getInstance().removeAllCookies((b)->{ + mWebview.clearHistory(); + mWebview.clearCache(true); + mWebview.clearFormData(); + mWebview.clearHistory(); + mWebview.loadUrl(mAuthUrl); + }); + } + + private void restoreWebViewState(Bundle savedInstanceState) { + Log.i("MSAuthFragment","Restoring state..."); + if(mWebview.restoreState(savedInstanceState) == null) { + Log.w("MSAuthFragment", "Failed to restore state, starting afresh"); + // if, for some reason, we failed to restore our session, + // just start afresh + startNewSession(); + } + } + + @Override + public void onStart() { + super.onStart(); + // If we have switched to a blank client and haven't fully gone though the lifecycle callbacks to restore it, + // restore it here. + if(mBlankClient) mWebview.setWebViewClient(new WebViewTrackClient()); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + // Since the value cannot be null, just create a "blank" client. This is done to not let Android + // kill us if something happens after the state gets saved, when we can't do fragment transitions + mWebview.setWebViewClient(new WebViewClient()); + // For some dumb reason state is saved even when Android won't actually destroy the activity. + // Let the fragment know that the client is blank so that we can restore it in onStart() + // (it was the earliest lifecycle call actually invoked in this case) + mBlankClient = true; + super.onSaveInstanceState(outState); + mWebview.saveState(outState); + } + + /* Expose webview actions to others */ + public boolean canGoBack(){ return mWebview.canGoBack();} + public void goBack(){ mWebview.goBack();} + + /** Client to track when to sent the data to the launcher */ + class WebViewTrackClient extends WebViewClient { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if(url.startsWith(mTrackedUrl)) { + internalSignalCompletion(url); + return true; + } + + return super.shouldOverrideUrlLoading(view, url); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) {} + + @Override + public void onPageFinished(WebView view, String url) { + if(url.startsWith(mTrackedUrl)) { + internalSignalCompletion(url); + } + } + } + + private void internalSignalCompletion(String fullUrl) { + if(mIsCompleted) return; + mIsCompleted = true; + signalCompletion(fullUrl); + } + + protected abstract void signalCompletion(String fullUrl); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java new file mode 100644 index 0000000000..589871bce7 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java @@ -0,0 +1,27 @@ +package net.kdt.pojavlaunch.instances; + +import java.io.File; + +public class DisplayInstance { + protected transient File mInstanceRoot; + public String name; + public String versionId; + public String icon; + + protected void sanitize() { + sanitizeIcon(); + } + + protected DisplayInstance() { + } + + protected File getInstanceIconLocation() { + return new File(mInstanceRoot, "icon.webp"); + } + + private void sanitizeIcon() { + if(!InstanceIconProvider.hasStaticIcon(icon)) { + icon = InstanceIconProvider.FALLBACK_ICON_NAME; + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java new file mode 100644 index 0000000000..244ea80bc9 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java @@ -0,0 +1,117 @@ +package net.kdt.pojavlaunch.instances; + +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.JSONUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +public class Instance extends DisplayInstance { + public static final int ARGS_MODE_REPLACE = 0; + public static final int ARGS_MODE_MERGE_DEFAULT_FIRST = 1; + public static final int ARGS_MODE_MERGE_INSTANCE_FIRST = 2; + public static final int ARGS_MODE_LAST = ARGS_MODE_MERGE_INSTANCE_FIRST; + + public static final String VERSION_LATEST_RELEASE = "latest_release"; + public static final String VERSION_LATEST_SNAPSHOT = "latest_snapshot"; + + public InstanceInstaller installer; + public String renderer; + public String jvmArgs; + public int argsMode; + public String selectedRuntime; + public String controlLayout; + public boolean sharedData; + + protected Instance() { + } + + @Override + protected void sanitize() { + super.sanitize(); + sanitizeArgs(); + } + + private void sanitizeArgs() { + if(argsMode > ARGS_MODE_LAST) { + argsMode = 0; + jvmArgs = null; + } + } + + /** + * Write the current contents of the instance to persistent storage. + * @throws IOException in case of write errors + */ + public void write() throws IOException { + JSONUtils.writeToFile(Instances.metadataLocation(mInstanceRoot), this); + } + + /** + * Try to write the contents of the instance, ignore any exceptions + */ + public void maybeWrite() { + try { + write(); + }catch (IOException e) { + Log.e("Instance", "Failed to write",e); + } + } + + /** + * Encode the Bitmap as the new profile icon with required encoding settings. + * @param bitmap the target bitmap + * @throws IOException in case of errors while storing the icon + */ + public void encodeNewIcon(Bitmap bitmap) throws IOException { + try(FileOutputStream fileOutputStream = new FileOutputStream(getInstanceIconLocation())) { + bitmap.compress( + Build.VERSION.SDK_INT < Build.VERSION_CODES.R ? + // On Android < 30, there was no distinction between "lossy" and "lossless", + // and the type is picked by the quality parameter. We set the quality to 60. + // so it should be lossy, + Bitmap.CompressFormat.WEBP: + // On Android >= 30, we can explicitly specify that we want lossy compression + // with the visual quality of 60. + Bitmap.CompressFormat.WEBP_LOSSY, + 60, + fileOutputStream + ); + } + } + + public String getLaunchRenderer() { + if(Tools.isValidString(renderer)) return renderer; + return LauncherPreferences.PREF_RENDERER; + } + + public String getLaunchArgs() { + if(!Tools.isValidString(jvmArgs)) return LauncherPreferences.PREF_CUSTOM_JAVA_ARGS; + switch (argsMode) { + case ARGS_MODE_REPLACE: + return jvmArgs; + case ARGS_MODE_MERGE_DEFAULT_FIRST: + return LauncherPreferences.PREF_CUSTOM_JAVA_ARGS + " " + jvmArgs; + case ARGS_MODE_MERGE_INSTANCE_FIRST: + return jvmArgs + " " + LauncherPreferences.PREF_CUSTOM_JAVA_ARGS; + default: + throw new RuntimeException("Unknown value for argsMode: "+argsMode); + } + } + + public String getLaunchControls() { + if(!Tools.isValidString(controlLayout)) return LauncherPreferences.PREF_DEFAULTCTRL_PATH; + return Tools.CTRLMAP_PATH + "/" + controlLayout; + } + + public File getGameDirectory() { + if(sharedData) return Instances.SHARED_DATA_DIRECTORY; + return mInstanceRoot; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java new file mode 100644 index 0000000000..0f2e032068 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java @@ -0,0 +1,128 @@ +package net.kdt.pojavlaunch.instances; + +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import androidx.core.graphics.ColorUtils; + +import git.artdeell.mojo.R; + +import net.kdt.pojavlaunch.Tools; + +import fr.spse.extended_view.ExtendedTextView; + +/* + * Adapter for listing launcher profiles in a Spinner + */ +public class InstanceAdapter extends BaseAdapter { + private Instances mInstances; + private int mSelectionIndex; + private final InstanceAdapterExtra[] mExtraEntires; + + + public InstanceAdapter(InstanceAdapterExtra[] extraEntries) { + if(extraEntries == null) extraEntries = new InstanceAdapterExtra[0]; + mExtraEntires = extraEntries; + } + /** + * @return how much entries (both instances and extra adapter entries) are in the adapter right now + */ + @Override + public int getCount() { + if(mInstances == null) return mExtraEntires.length; + return mInstances.list.size() + mExtraEntires.length; + } + /** + * Gets the adapter entry at a given index + * @param position index to retrieve + * @return Instance, ProfileAdapterExtra or null + */ + @Override + public Object getItem(int position) { + if(mInstances == null) return mExtraEntires[position]; + int instanceListSize = mInstances.list.size(); + int extraPosition = position - instanceListSize; + if(position < instanceListSize) { + return mInstances.list.get(position); + }else if(extraPosition >= 0 && extraPosition < mExtraEntires.length){ + return mExtraEntires[extraPosition]; + } + return null; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = convertView; + if (v == null) v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_version_profile_layout,parent,false); + setView(v, position, true); + return v; + } + + public void setViewInstance(View v, DisplayInstance i, int idx, boolean displaySelection) { + ExtendedTextView extendedTextView = (ExtendedTextView) v; + + //MinecraftProfile minecraftProfile = mProfiles.get(nm); + //if(minecraftProfile == null) minecraftProfile = dummy; + Drawable cachedIcon = InstanceIconProvider.fetchIcon(v.getResources(), i); + extendedTextView.setCompoundDrawablesRelative(cachedIcon, null, extendedTextView.getCompoundsDrawables()[2], null); + + // Historically, the profile name "New" was hardcoded as the default profile name + // We consider "New" the same as putting no name at all + + String profileName = Tools.validOrNullString(i.name); + String versionName = Tools.validOrNullString(i.versionId); + + if (Instance.VERSION_LATEST_RELEASE.equalsIgnoreCase(versionName)) + versionName = v.getContext().getString(R.string.profiles_latest_release); + else if (Instance.VERSION_LATEST_SNAPSHOT.equalsIgnoreCase(versionName)) + versionName = v.getContext().getString(R.string.profiles_latest_snapshot); + + if (versionName == null && profileName != null) + extendedTextView.setText(profileName); + else if (versionName != null && profileName == null) + extendedTextView.setText(versionName); + else extendedTextView.setText(String.format("%s - %s", profileName, versionName)); + + // Set selected background if needed + if(idx == mSelectionIndex && displaySelection) { + extendedTextView.setBackgroundColor(ColorUtils.setAlphaComponent(Color.WHITE, 60)); + }else { + extendedTextView.setBackgroundColor(Color.TRANSPARENT); + } + } + + public void setViewExtra(View v, InstanceAdapterExtra extra) { + ExtendedTextView extendedTextView = (ExtendedTextView) v; + extendedTextView.setCompoundDrawablesRelative(extra.icon, null, extendedTextView.getCompoundsDrawables()[2], null); + extendedTextView.setText(extra.name); + extendedTextView.setBackgroundColor(Color.TRANSPARENT); + } + + public void setView(View v, int index, boolean displaySelection) { + Object object = getItem(index); + if(object instanceof DisplayInstance) { + setViewInstance(v, (DisplayInstance) object, index, displaySelection); + }else if(object instanceof InstanceAdapterExtra) { + setViewExtra(v, (InstanceAdapterExtra) object); + } + } + + public void applySelectionIndex(int index) { + mSelectionIndex = index; + } + + public void applyInstances(Instances instances) { + mInstances = instances; + mSelectionIndex = instances.selectedIndex; + notifyDataSetChanged(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java new file mode 100644 index 0000000000..46fa5bf8de --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java @@ -0,0 +1,15 @@ +package net.kdt.pojavlaunch.instances; + +import android.graphics.drawable.Drawable; + +public class InstanceAdapterExtra { + public final int id; + public final int name; + public final Drawable icon; + + public InstanceAdapterExtra(int id, int name, Drawable icon) { + this.id = id; + this.name = name; + this.icon = icon; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java new file mode 100644 index 0000000000..cdbf86c9ba --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java @@ -0,0 +1,109 @@ +package net.kdt.pojavlaunch.instances; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; + +import git.artdeell.mojo.R; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class InstanceIconProvider { + public static final String FALLBACK_ICON_NAME = "default"; + private static final Map sIconCache = new HashMap<>(); + private static final Map sStaticIconCache = new HashMap<>(); + private static final Map sStaticIcons = new HashMap<>(); + + static { + sStaticIcons.put("default", R.drawable.ic_mojo_full); + sStaticIcons.put("fabric", R.drawable.ic_fabric); + sStaticIcons.put("quilt", R.drawable.ic_quilt); + sStaticIcons.put("forge", R.drawable.ic_forge); + sStaticIcons.put("neoforge", R.drawable.ic_neoforge); + } + + /** + * Fetch an icon from the cache, or load it if it's not cached. + * @param resources the Resources object, used for creating drawables + * @param instance the instance + * @return an icon drawable + */ + public static @NonNull Drawable fetchIcon(Resources resources, @NonNull DisplayInstance instance) { + int identityHashCode = System.identityHashCode(instance); + + Drawable cachedIcon = sIconCache.get(identityHashCode); + if(cachedIcon != null) return cachedIcon; + + Drawable instanceIcon = fetchInstanceFileIcon(resources, identityHashCode, instance.getInstanceIconLocation()); + if(instanceIcon != null) return instanceIcon; + + return fetchStaticIcon(resources, identityHashCode, instance.icon); + } + + /** + * Drop an icon from the icon cache. When dropped, it's Drawable will be re-read from the + * instance icon file (or re-fetched from the static cache) + * @param key the instance + */ + public static void dropIcon(@NonNull Instance key) { + sIconCache.remove(System.identityHashCode(key)); + } + + private static Drawable fetchInstanceFileIcon(Resources resources, int identityHash, File iconLocation) { + if(!iconLocation.isFile() || !iconLocation.canRead()) return null; + Bitmap iconBitmap = BitmapFactory.decodeFile(iconLocation.getAbsolutePath()); + if(iconBitmap == null) return null; + Drawable iconDrawable = new BitmapDrawable(resources, iconBitmap); + sIconCache.put(identityHash, iconDrawable); + return iconDrawable; + } + + private static Drawable fetchStaticIcon(Resources resources, int identityHash, String icon) { + Drawable staticIcon = sStaticIconCache.get(icon); + if(staticIcon == null) { + if(icon != null) staticIcon = getStaticIcon(resources, icon); + if(staticIcon == null) staticIcon = fetchFallbackIcon(resources); + sStaticIconCache.put(icon, staticIcon); + } + sIconCache.put(identityHash, staticIcon); + return staticIcon; + } + + private static @NonNull Drawable fetchFallbackIcon(Resources resources) { + Drawable fallbackIcon = sStaticIconCache.get(FALLBACK_ICON_NAME); + if(fallbackIcon == null) { + fallbackIcon = Objects.requireNonNull(getStaticIcon(resources, FALLBACK_ICON_NAME)); + sStaticIconCache.put(FALLBACK_ICON_NAME, fallbackIcon); + } + return fallbackIcon; + } + + private static Drawable getStaticIcon(Resources resources, @NonNull String icon) { + int staticIconResource = getStaticIconResource(icon); + if(staticIconResource == -1) return null; + return ResourcesCompat.getDrawable(resources, staticIconResource, null); + } + + private static int getStaticIconResource(String icon) { + Integer iconResource = sStaticIcons.get(icon); + if(iconResource == null) return -1; + return iconResource; + } + + /** + * Check whether the icon under the specified name is a static icon available in the provider. + * @param name static icon name to check + * @return whether the icon is available or not + */ + public static boolean hasStaticIcon(String name) { + return sStaticIcons.containsKey(name); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java new file mode 100644 index 0000000000..8d661f124b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java @@ -0,0 +1,179 @@ +package net.kdt.pojavlaunch.instances; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.AssetManager; +import android.os.Bundle; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.JavaGUILauncherActivity; +import net.kdt.pojavlaunch.LauncherActivity; +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.extra.ExtraConstants; +import net.kdt.pojavlaunch.extra.ExtraCore; +import net.kdt.pojavlaunch.instances.profcompat.ProfileWatcher; +import net.kdt.pojavlaunch.lifecycle.ContextExecutor; +import net.kdt.pojavlaunch.lifecycle.ContextExecutorTask; +import net.kdt.pojavlaunch.modloaders.OFDownloadPageScraper; +import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper; +import net.kdt.pojavlaunch.utils.DownloadUtils; +import net.kdt.pojavlaunch.utils.JSONUtils; +import net.kdt.pojavlaunch.utils.NotificationUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import git.artdeell.mojo.R; + +public class InstanceInstaller implements ContextExecutorTask { + private static final File sLastInstallInfo = new File(Tools.DIR_CACHE, "last_installer.json"); + + private static final String[] TRUSTED_URLS = new String[] { + "https://maven.neoforged.net/releases/net/neoforged/neoforge/", + "https://maven.minecraftforge.net/net/minecraftforge/forge/", + "https://optifine.net/adloadx" + }; + + public String installerJar; + private transient File installerJarFile; + private transient String mTransformedUrl; + public List commandLineArgs; + public String installerUrlTransformer; + public String installerDownloadUrl; + public String installerSha1; + + private File installerJar() { + if(installerJarFile == null) return installerJarFile = new File(installerJar); + return installerJarFile; + } + + private String installerDownloadUrl() throws IOException{ + if(mTransformedUrl != null) return mTransformedUrl; + String newUrl; + if ("optifine".equals(installerUrlTransformer)) { + newUrl = OFDownloadPageScraper.run(installerDownloadUrl); + }else { + newUrl = installerDownloadUrl; + } + mTransformedUrl = newUrl; + return newUrl; + } + + private void writeLastInstaller() throws IOException { + JSONUtils.writeToFile(sLastInstallInfo, this); + } + + public void threadedStart() throws IOException { + try { + final byte[] buffer = new byte[8192]; + final DownloaderProgressWrapper wrapper = new DownloaderProgressWrapper( + R.string.mcl_launch_downloading_progress, ProgressLayout.INSTANCE_INSTALL + ); + wrapper.extraString = installerJar().getName(); + DownloadUtils.ensureSha1(installerJar(), installerSha1, ()->{ + DownloadUtils.downloadFileMonitored(installerDownloadUrl(), installerJar(), buffer, wrapper); + return null; + }); + ContextExecutor.execute(this); + } finally { + ProgressLayout.clearProgress(ProgressLayout.INSTANCE_INSTALL); + } + } + + public static void postInstallCheck(AssetManager assetManager) throws IOException { + if(!sLastInstallInfo.exists() || !sLastInstallInfo.isFile()) return; + InstanceInstaller lastInstaller = JSONUtils.readFromFile(sLastInstallInfo, InstanceInstaller.class); + boolean ignored = lastInstaller.installerJar().delete(); + if(!sLastInstallInfo.delete()) throw new IOException("Failed to delete mod installer info"); + String targetVersionId = ProfileWatcher.consumePendingVersion(assetManager); + if(targetVersionId == null) return; + for(Instance instance : Instances.loadAllInstances()) { + if(!lastInstaller.equals(instance.installer)) continue; + instance.installer = null; + instance.versionId = targetVersionId; + instance.write(); + } + ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, null); + } + + public static void postInstallCheck(Context context) { + try { + InstanceInstaller.postInstallCheck(context.getAssets()); + }catch (Exception e) { + Tools.showError(context, e); + if (sLastInstallInfo.isFile()) { + boolean ignored = sLastInstallInfo.delete(); + } + } + } + + public void start() { + ProgressLayout.setProgress(ProgressLayout.INSTANCE_INSTALL, 0); + PojavApplication.sExecutorService.execute(()->{ + try { + threadedStart(); + }catch (Exception e) { + Tools.showErrorRemote(e); + } + }); + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof InstanceInstaller)) return false; + InstanceInstaller that = (InstanceInstaller) object; + return Objects.equals(installerJar, that.installerJar) && + Objects.equals(commandLineArgs, that.commandLineArgs) && + Objects.equals(installerDownloadUrl, that.installerDownloadUrl) && + Objects.equals(installerUrlTransformer, that.installerUrlTransformer) && + Objects.equals(installerSha1, that.installerSha1); + } + + @Override + public int hashCode() { + return Objects.hash(installerJar, commandLineArgs, installerDownloadUrl, installerUrlTransformer, installerSha1); + } + + private boolean isTrustedInstaller() { + for(String frontTrusted : TRUSTED_URLS) { + if(installerDownloadUrl.startsWith(frontTrusted)) return true; + } + return false; + } + + @Override + public void executeWithActivity(Activity activity) { + try { + ProfileWatcher.installDefaultProfiles(activity.getAssets()); + writeLastInstaller(); + }catch (Exception e) { + Tools.showError(activity, e); + return; + } + Intent intent = new Intent(activity, JavaGUILauncherActivity.class); + Bundle extras = new Bundle(); + extras.putStringArrayList("javaArgs", new ArrayList<>(commandLineArgs)); + extras.putString("modPath", installerJar); + extras.putBoolean("trusted", isTrustedInstaller()); + intent.putExtras(extras); + activity.startActivity(intent); + } + + @Override + public void executeWithApplication(Context context) { + Tools.runOnUiThread(() -> NotificationUtils.sendBasicNotification(context, + R.string.modpack_install_notification_title, + R.string.modpack_install_notification_success, + new Intent(context, LauncherActivity.class), + NotificationUtils.PENDINGINTENT_CODE_DOWNLOAD_SERVICE, + NotificationUtils.NOTIFICATION_ID_DOWNLOAD_LISTENER + )); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java new file mode 100644 index 0000000000..6db74a262f --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java @@ -0,0 +1,5 @@ +package net.kdt.pojavlaunch.instances; + +public interface InstanceSetter { + void setInstanceProperties(Instance instance); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java new file mode 100644 index 0000000000..f9d11163be --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java @@ -0,0 +1,193 @@ +package net.kdt.pojavlaunch.instances; + +import com.google.gson.JsonSyntaxException; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.FileUtils; +import net.kdt.pojavlaunch.utils.JSONUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class Instances { + private static final File sInstancePath = new File(Tools.DIR_GAME_HOME, "instances"); + public static final File SHARED_DATA_DIRECTORY = new File(Tools.DIR_GAME_HOME, "shared_dir"); + + public final List list; + public final int selectedIndex; + + private Instances(List instances, int selectedIndex) { + this.list = instances; + this.selectedIndex = selectedIndex; + } + + private static T read(File instanceRoot, Class tClass) { + try { + T instance = JSONUtils.readFromFile(metadataLocation(instanceRoot), tClass); + if(instance == null) return null; + instance.mInstanceRoot = instanceRoot; + return instance; + }catch (IOException | JsonSyntaxException e) { + return null; + } + } + + protected static File metadataLocation(File instanceDir) { + return new File(instanceDir, "mojo_instance.json"); + } + + private static File selectedInstanceLocation() { + String directoryName = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_INSTANCE, ""); + File instanceRoot = new File(sInstancePath, directoryName); + if(!metadataLocation(instanceRoot).exists()) return null; + return instanceRoot; + } + + private static boolean filterInstanceDirectories(File instanceDir) { + if(!instanceDir.canRead() || !instanceDir.canWrite()) return false; + if(!instanceDir.isDirectory()) return false; + File instanceMetadata = metadataLocation(instanceDir); + if(!instanceMetadata.isFile()) return false; + return instanceMetadata.canRead(); + } + + private static List loadInstances(Class tClass, int[] selectionDst) throws IOException { + synchronized (sInstancePath) { + FileUtils.ensureDirectory(sInstancePath); + } + File[] instanceDirectories = sInstancePath.listFiles(Instances::filterInstanceDirectories); + if(instanceDirectories == null) throw new IOException("Failed to enumerate instances"); + File selectedInstanceLocation = selectionDst != null ? selectedInstanceLocation() : null; + ArrayList instances = new ArrayList<>(instanceDirectories.length); + + for(File instanceDir : instanceDirectories) { + T instance = read(instanceDir, tClass); + + if(instance == null) continue; + instance.sanitize(); + instances.add(instance); + + if(selectionDst != null && instanceDir.equals(selectedInstanceLocation)) { + selectionDst[0] = instances.size() - 1; + } + } + instances.trimToSize(); + return instances; + } + + public static Instances loadDisplay() throws IOException { + int[] selectionIndex = new int[] { -1 }; + List instances = loadInstances(DisplayInstance.class, selectionIndex); + if(instances.isEmpty()) { + createFirstTimeInstance(); + return loadDisplay(); + }else if(selectionIndex[0] == -1) { + setSelectedInstance(instances.get(0)); + selectionIndex[0] = 0; + } + return new Instances(Collections.unmodifiableList(instances), selectionIndex[0]); + } + + public static List loadAllInstances() throws IOException { + return loadInstances(Instance.class, null); + } + + private static File findNewInstanceRoot(String prefix) { + File instanceRoot; + do { + String proposedDirectoryName = UUID.randomUUID().toString(); + if(prefix != null) { + proposedDirectoryName = prefix + "-" + proposedDirectoryName; + } + instanceRoot = new File(sInstancePath, proposedDirectoryName); + } while(instanceRoot.exists() && instanceRoot.isDirectory()); + return instanceRoot; + } + + /** + * Set the currently selected instance and save it in user preferences + * @param instance new selected instance + */ + public static void setSelectedInstance(DisplayInstance instance) { + LauncherPreferences.DEFAULT_PREF.edit() + .putString( + LauncherPreferences.PREF_KEY_CURRENT_INSTANCE, + instance.mInstanceRoot.getName() + ).apply(); + } + + /** + * Remove the instance. This also removes its data storage folder. + * @param instance the Instance to remove + * @throws IOException in case of errors during directory removal + */ + public static void removeInstance(Instance instance) throws IOException { + File instanceDirectory = instance.mInstanceRoot; + if(instanceDirectory == null) return; + org.apache.commons.io.FileUtils.deleteDirectory(instanceDirectory); + } + + /** + * Create a new instance intended for first-time launcher users. + */ + private static void createFirstTimeInstance() throws IOException { + internalCreateInstance((instance)-> { + instance.sharedData = true; + instance.versionId = "1.12.2"; + }, null); + } + + /** + * Create a new instance based on a default template. + * @return the new instance + */ + public static Instance createDefaultInstance() throws IOException { + return createInstance((instance)-> { + instance.sharedData = true; + instance.versionId = Instance.VERSION_LATEST_RELEASE; + }, null); + } + + /** + * Create an instance without attempting to load the instance list first. Only use this + * method during initialization. + */ + private static Instance internalCreateInstance(InstanceSetter instanceSetter, String namePrefix) throws IOException{ + File root = findNewInstanceRoot(namePrefix); + FileUtils.ensureDirectory(root); + Instance instance = new Instance(); + instance.mInstanceRoot = root; + instanceSetter.setInstanceProperties(instance); + instance.write(); + return instance; + } + + /** + * Create a new instance with defaults set by user + * @param instanceSetter setter function called to set user parameters + * @param namePrefix a name prefix (for the user to easily distinguish installed instances) + * @return the created instance + * @throws IOException if directory creation/instance writing fails + */ + public static Instance createInstance(InstanceSetter instanceSetter, String namePrefix) throws IOException { + return internalCreateInstance(instanceSetter, namePrefix); + } + + /** + * Load the currently selected instance. Note that this method must not be used along with any code + * which uses getImmutableInstanceList() + * @return currently selected instance + */ + public static Instance loadSelectedInstance() { + File selectedInstanceLocation = selectedInstanceLocation(); + Instance instance = read(selectedInstanceLocation, Instance.class); + if(instance == null) return null; + instance.sanitize(); + return instance; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java new file mode 100644 index 0000000000..db584a46f4 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java @@ -0,0 +1,6 @@ +package net.kdt.pojavlaunch.instances.profcompat; + +public class ProfileBody { + public String name; + public String lastVersionId; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java new file mode 100644 index 0000000000..3925b3c844 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java @@ -0,0 +1,34 @@ +package net.kdt.pojavlaunch.instances.profcompat; + +import android.content.res.AssetManager; + +import net.kdt.pojavlaunch.Tools; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Iterator; +import java.util.Map; + +public class ProfileWatcher { + private static final File sLauncherProfiles = new File(Tools.DIR_GAME_NEW, "launcher_profiles.json"); + public static String consumePendingVersion(AssetManager assetManager) throws IOException { + Profiles store; + try(FileReader fileReader = new FileReader(sLauncherProfiles)) { + store = Tools.GLOBAL_GSON.fromJson(fileReader, Profiles.class); + } + Map profiles = store.profiles; + String versionId = null; + for (Map.Entry entry : profiles.entrySet()) { + if ("(Default)".equals(entry.getKey())) continue; + versionId = entry.getValue().lastVersionId; + if(versionId != null) break; + } + installDefaultProfiles(assetManager); + return versionId; + } + public static void installDefaultProfiles(AssetManager assetManager) throws IOException { + Tools.copyAssetFile(assetManager, "launcher_profiles.json", sLauncherProfiles, true); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java new file mode 100644 index 0000000000..e3bd99e897 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java @@ -0,0 +1,8 @@ +package net.kdt.pojavlaunch.instances.profcompat; + +import java.util.Map; + +public class Profiles { + public Map profiles; + @SuppressWarnings("unused") public String selectedProfile; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java new file mode 100644 index 0000000000..de60f55394 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java @@ -0,0 +1,12 @@ +package net.kdt.pojavlaunch.lifecycle; + +import android.app.Activity; + +public interface ActivityRunnable { + /** + * ContextExecutor will execute this function first if a foreground Activity that was attached to the + * ContextExecutor is available. + * @param activity the activity + */ + void executeWithActivity(Activity activity); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java index 2cced7b7ee..566f87a6c6 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java @@ -76,14 +76,18 @@ public static void downloadFileMirrored(int downloadClass, String urlInput, File * @param urlInput The original (Mojang) URL for the download * @return the length of the file denoted by the URL in bytes, or -1 if not available */ - public static long getContentLengthMirrored(int downloadClass, String urlInput) throws IOException { - long length = DownloadUtils.getContentLength(getMirrorMapping(downloadClass, urlInput)); - if(length < 1) { - Log.w("DownloadMirror", "Unable to get content length from mirror"); - Log.i("DownloadMirror", "Falling back to default source"); - return DownloadUtils.getContentLength(urlInput); - }else { - return length; + public static long getContentLengthMirrored(int downloadClass, String urlInput){ + try { + long length = DownloadUtils.getContentLength(getMirrorMapping(downloadClass, urlInput)); + if (length < 1) { + Log.w("DownloadMirror", "Unable to get content length from mirror"); + Log.i("DownloadMirror", "Falling back to default source"); + return DownloadUtils.getContentLength(urlInput); + } else { + return length; + } + } catch (IOException ignored) { // If error happens, fallback to old file counter instead of size. This shouldn't really happen unless offline though. + return -1L; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java new file mode 100644 index 0000000000..649ffd6f6d --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java @@ -0,0 +1,72 @@ +package net.kdt.pojavlaunch.modloaders; + +public class ComparableVersionString implements Comparable { + private int major; + private int minor; + private int patch; + private final String original; + private final boolean isValid; + + private ComparableVersionString(String str) { + this.original = str; + this.isValid = false; + } + + public ComparableVersionString(String original, int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + this.original = original; + this.isValid = true; + } + + @Override + public int compareTo(ComparableVersionString str) { + if(!this.isValid) return str.getProper().compareTo(this.original); + + if(this.major != str.major) return Integer.compare(this.major, str.major); + if(this.minor != str.minor) return Integer.compare(this.minor, str.minor); + if(this.patch != str.patch) return Integer.compare(this.patch, str.patch); + return 0; + } + + public String getOriginal() { + return original; + } + + /** + * @return the original but if the patch was .0 it will not include it, e.g. + * "1.20.0" -> "1.20" + */ + public String getProper() { + if(!this.isValid) return original; + + StringBuilder sb = new StringBuilder(); + sb.append(major); + sb.append('.'); + sb.append(minor); + if(patch != 0) { + sb.append('.'); + sb.append(patch); + } + return sb.toString(); + } + + public boolean isValid() { + return isValid; + } + + public static ComparableVersionString parse(String str) { + String[] split = str.split("\\."); + if (split.length < 2) return new ComparableVersionString(str); + + try { + int major = Integer.parseInt(split[0]); + int minor = Integer.parseInt(split[1]); + int patch = (split.length >= 3) ? Integer.parseInt(split[2]) : 0; + return new ComparableVersionString(str, major, minor, patch); + } catch (NumberFormatException e) { + return new ComparableVersionString(str); + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java new file mode 100644 index 0000000000..8367daf910 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java @@ -0,0 +1,176 @@ +package net.kdt.pojavlaunch.modloaders; + +import android.util.Log; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.instances.InstanceInstaller; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.util.List; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +public abstract class ForgelikeUtils { + public static final ForgelikeUtils FORGE_UTILS = new ForgeUtils(); + public static final ForgelikeUtils NEOFORGE_UTILS = new NeoforgeUtils(); + + private final String mName; + private final String mCachePrefix; + private final String mVersionResolver; + private final String mIconName; + private final String mMetadataUrl; + private final String mInstallerUrl; + private final boolean mVersionOrderInversed; + + private ForgelikeUtils(String name, String cachePrefix, String iconName, String versionResolver, String metadataUrl, String installerUrl, boolean versionOrderInversed) { + this.mName = name; + this.mCachePrefix = cachePrefix; + this.mIconName = iconName; + this.mVersionResolver = versionResolver; + this.mMetadataUrl = metadataUrl; + this.mInstallerUrl = installerUrl; + this.mVersionOrderInversed = versionOrderInversed; + } + + public List downloadVersions() throws IOException { + SAXParser saxParser; + try { + SAXParserFactory parserFactory = SAXParserFactory.newInstance(); + saxParser = parserFactory.newSAXParser(); + } catch (SAXException | ParserConfigurationException e) { + e.printStackTrace(); + // if we cant make a parser we might as well not even try to parse anything + return null; + } + try { + //of_test(); + return DownloadUtils.downloadStringCached(mMetadataUrl, mCachePrefix + "_versions", input -> { + try { + ForgelikeVersionListHandler handler = new ForgelikeVersionListHandler(); + saxParser.parse(new InputSource(new StringReader(input)), handler); + return handler.getVersions(); + // IOException is present here StringReader throws it only if the parser called close() + // sooner than needed, which is a parser issue and not an I/O one + } catch (SAXException | IOException e) { + throw new DownloadUtils.ParseException(e); + } + }); + } catch (DownloadUtils.ParseException e) { + e.printStackTrace(); + return null; + } + } + + public String getInstallerUrl(String version) { + return String.format(mInstallerUrl, version); + } + + public InstanceInstaller createInstaller(String gameVersion, String modLoaderVersion) throws IOException { + List versions = downloadVersions(); + if (versions == null) return null; + String versionStart = String.format(mVersionResolver, gameVersion, modLoaderVersion); + for (String versionName : versions) { + if (!versionName.startsWith(versionStart)) continue; + return createInstaller(versionName); + } + return null; + } + + public InstanceInstaller createInstaller(String fullVersion) throws IOException { + String downloadUrl = getInstallerUrl(fullVersion); + String hash = DownloadUtils.downloadString(downloadUrl + ".sha1"); + File installerLocation = new File(Tools.DIR_CACHE, mCachePrefix + "-installer-" + fullVersion + ".jar"); + InstanceInstaller instanceInstaller = new InstanceInstaller(); + instanceInstaller.commandLineArgs = List.of("-Duser.language=en", "-Duser.country=US", "-javaagent:"+Tools.DIR_DATA+"/forge_installer/forge_installer.jar"); + instanceInstaller.installerJar = installerLocation.getAbsolutePath(); + instanceInstaller.installerSha1 = hash; + instanceInstaller.installerDownloadUrl = downloadUrl; + return instanceInstaller; + } + + public String getName() { + return mName; + } + + public String getIconName() { + return mIconName; + } + + public abstract String processVersionString(String version); + + public abstract boolean shouldSkipVersion(String version); + + public boolean isVersionOrderInversed() { + return mVersionOrderInversed; + } + + private static String getMcVersionForNeoVersion(String neoVersion) { + // I feel like it's necessary to explain the NeoForge versioning format + // basically, what it does is it trims the major version from minecrafts version + // e.g.: 1.20.1 -> 20.1, and then appends its own "patch" version to that + // e.g.: 20.1 -> 20.1.8, which means the version string includes both, the minecraft + // and the loader version at once + try { + int firstIndex = neoVersion.indexOf('.'); + int secondIndex = neoVersion.indexOf('.', firstIndex + 1); + if (firstIndex == -1 || secondIndex == -1) { + Log.e("NeoforgeUtils", "Failed to parse neoforge version: " + neoVersion + "; not enough '.' found"); + } + return "1." + neoVersion.substring(0, secondIndex); + } catch (StringIndexOutOfBoundsException e) { + Log.e("NeoforgeUtils", "Failed to parse neoforge version: " + neoVersion, e); + return neoVersion; + } + } + + private static class ForgeUtils extends ForgelikeUtils { + public ForgeUtils() { + super( + "Forge", "forge", "forge", "%1$s-%2$s", + "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml", + "https://maven.minecraftforge.net/net/minecraftforge/forge/%1$s/forge-%1$s-installer.jar", + false + ); + } + + @Override + public String processVersionString(String version) { + int dashIndex = version.indexOf("-"); + return version.substring(0, dashIndex); + } + + @Override + public boolean shouldSkipVersion(String version) { + return false; + } + } + + private static class NeoforgeUtils extends ForgelikeUtils { + public NeoforgeUtils() { + super( + "NeoForge", "neoforge", "neoforge", "%2$s", + "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml", + "https://maven.neoforged.net/releases/net/neoforged/neoforge/%1$s/neoforge-%1$s-installer.jar", + true + ); + } + + @Override + public String processVersionString(String version) { + return ComparableVersionString.parse(getMcVersionForNeoVersion(version)).getProper(); + } + + @Override + public boolean shouldSkipVersion(String version) { + return version.startsWith("0"); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java new file mode 100644 index 0000000000..e0eaee27c9 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java @@ -0,0 +1,118 @@ +package net.kdt.pojavlaunch.modloaders; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.ExpandableListAdapter; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; + +public class ForgelikeVersionListAdapter extends BaseExpandableListAdapter implements ExpandableListAdapter { + private final List mGameVersions; + private final List> mLoaderVersions; + private final LayoutInflater mLayoutInflater; + + public ForgelikeVersionListAdapter(List forgeVersions, LayoutInflater layoutInflater, ForgelikeUtils utils) { + this.mLayoutInflater = layoutInflater; + mGameVersions = new ArrayList<>(); + mLoaderVersions = new ArrayList<>(); + for(String version : forgeVersions) { + if(utils.shouldSkipVersion(version)) continue; + String gameVersion = utils.processVersionString(version); + List versionList; + int gameVersionIndex = mGameVersions.indexOf(gameVersion); + if(gameVersionIndex != -1) { + versionList = mLoaderVersions.get(gameVersionIndex); + } else { + versionList = new ArrayList<>(); + mGameVersions.add(gameVersion); + mLoaderVersions.add(versionList); + } + versionList.add(version); + } + if(utils.isVersionOrderInversed()) { + for (List versionList : mLoaderVersions) { + reverseList(versionList); + } + reverseList(mLoaderVersions); + reverseList(mGameVersions); + } + } + + @Override + public int getGroupCount() { + return mGameVersions.size(); + } + + @Override + public int getChildrenCount(int i) { + return mLoaderVersions.get(i).size(); + } + + @Override + public Object getGroup(int i) { + return getGameVersion(i); + } + + @Override + public Object getChild(int i, int i1) { + return getForgeVersion(i, i1); + } + + @Override + public long getGroupId(int i) { + return i; + } + + @Override + public long getChildId(int i, int i1) { + return i1; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public View getGroupView(int i, boolean b, View convertView, ViewGroup viewGroup) { + if(convertView == null) + convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false); + + ((TextView) convertView).setText(getGameVersion(i)); + + return convertView; + } + + @Override + public View getChildView(int i, int i1, boolean b, View convertView, ViewGroup viewGroup) { + if(convertView == null) + convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false); + ((TextView) convertView).setText(getForgeVersion(i, i1)); + return convertView; + } + + private String getGameVersion(int i) { + return mGameVersions.get(i); + } + + private String getForgeVersion(int i, int i1){ + return mLoaderVersions.get(i).get(i1); + } + + @Override + public boolean isChildSelectable(int i, int i1) { + return true; + } + + private static void reverseList(List list) { + for (int i = 0, j = list.size() - 1; i < j; i++, j--) { + T temp = list.get(i); + list.set(i, list.get(j)); + list.set(j, temp); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java new file mode 100644 index 0000000000..29cb6c3130 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java @@ -0,0 +1,39 @@ +package net.kdt.pojavlaunch.modloaders; + +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +import java.util.ArrayList; +import java.util.List; + +public class ForgelikeVersionListHandler extends DefaultHandler { + private List mForgeVersions; + private StringBuilder mCurrentVersion = null; + @Override + public void startDocument() throws SAXException { + mForgeVersions = new ArrayList<>(); + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + if(mCurrentVersion != null) mCurrentVersion.append(ch, start, length); + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException { + if(qName.equals("version")) mCurrentVersion = new StringBuilder(); + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + if (qName.equals("version")) { + String version = mCurrentVersion.toString(); + mForgeVersions.add(version); + mCurrentVersion = null; + } + } + public List getVersions() { + return mForgeVersions; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java new file mode 100644 index 0000000000..68a81977e7 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java @@ -0,0 +1,367 @@ +package net.kdt.pojavlaunch.modloaders; + +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.R; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class InstalledModAdapter extends RecyclerView.Adapter { + + private static final String TAG = "ModAdapter"; + + public interface EmptyStateListener { + void onEmptyStateChanged(boolean isEmpty); + } + + private final List mMods = new ArrayList<>(); + private final EmptyStateListener mEmptyListener; + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); + + public InstalledModAdapter(File modsDir, EmptyStateListener listener) { + mEmptyListener = listener; + if (modsDir != null && modsDir.isDirectory()) { + File[] files = modsDir.listFiles(f -> f.isFile() && + (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + if (files != null) { + Arrays.sort(files, (a, b) -> a.getName().compareToIgnoreCase(b.getName())); + for (File f : files) mMods.add(new ModEntry(f)); + } + } + notifyEmptyState(); + } + + @NonNull + @Override + public ModViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_installed_mod, parent, false); + return new ModViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull ModViewHolder holder, int position) { + holder.bind(mMods.get(position)); + } + + @Override + public void onViewRecycled(@NonNull ModViewHolder holder) { + // Clear the tag so any in-flight load doesn't update this recycled view + holder.icon.setTag(null); + holder.icon.setImageResource(R.drawable.ic_add_modded); + super.onViewRecycled(holder); + } + + @Override + public int getItemCount() { return mMods.size(); } + + private void notifyEmptyState() { + if (mEmptyListener != null) mEmptyListener.onEmptyStateChanged(mMods.isEmpty()); + } + + // ── ViewHolder ──────────────────────────────────────────────────────── + + class ModViewHolder extends RecyclerView.ViewHolder { + final ImageView icon; + final TextView name, version; + final SwitchCompat toggle; + final ImageButton delete; + + ModViewHolder(@NonNull View itemView) { + super(itemView); + icon = itemView.findViewById(R.id.installed_mod_icon); + name = itemView.findViewById(R.id.installed_mod_name); + version = itemView.findViewById(R.id.installed_mod_version); + toggle = itemView.findViewById(R.id.installed_mod_toggle); + delete = itemView.findViewById(R.id.installed_mod_delete); + } + + void bind(ModEntry entry) { + name.setText(entry.displayName()); + version.setText(entry.file.getName()); + + // Tag the ImageView with the file path so we can verify it hasn't been recycled + icon.setTag(entry.file.getAbsolutePath()); + icon.setImageResource(R.drawable.ic_add_modded); + + final String expectedTag = entry.file.getAbsolutePath(); + final WeakReference iconRef = new WeakReference<>(icon); + final File jarFile = entry.file; + + PojavApplication.sExecutorService.execute(() -> { + Bitmap bmp = extractModIcon(jarFile); + if (bmp == null) return; + mMainHandler.post(() -> { + ImageView iv = iconRef.get(); + // Only update if the view still belongs to the same mod + if (iv != null && expectedTag.equals(iv.getTag())) { + iv.setImageBitmap(bmp); + } + }); + }); + + toggle.setOnCheckedChangeListener(null); + toggle.setChecked(entry.enabled); + toggle.setOnCheckedChangeListener((btn, checked) -> entry.setEnabled(checked)); + + delete.setOnClickListener(v -> { + Context ctx = v.getContext(); + new AlertDialog.Builder(ctx) + .setTitle(ctx.getString(R.string.manage_mods_delete_confirm, entry.displayName())) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (d, i) -> { + entry.file.delete(); + int p = getBindingAdapterPosition(); + if (p != RecyclerView.NO_POSITION) { + mMods.remove(p); + notifyItemRemoved(p); + notifyEmptyState(); + } + }) + .show(); + }); + } + } + + // ── ModEntry ────────────────────────────────────────────────────────── + + static class ModEntry { + File file; + boolean enabled; + + ModEntry(File f) { + this.file = f; + this.enabled = !f.getName().endsWith(".disabled"); + } + + String displayName() { + String n = file.getName(); + if (n.endsWith(".jar.disabled")) n = n.substring(0, n.length() - 13); + else if (n.endsWith(".jar")) n = n.substring(0, n.length() - 4); + return n; + } + + void setEnabled(boolean enable) { + if (enable == this.enabled) return; + File target = enable + ? new File(file.getParent(), file.getName().replace(".jar.disabled", ".jar")) + : new File(file.getParent(), file.getName() + ".disabled"); + if (file.renameTo(target)) { + file = target; + this.enabled = enable; + } + } + } + + // ── Icon extraction ─────────────────────────────────────────────────── + + @Nullable + private static Bitmap extractModIcon(File jarFile) { + try (ZipFile zip = new ZipFile(jarFile)) { + String iconPath = resolveIconPath(zip); + Log.d(TAG, jarFile.getName() + " → icon path: " + iconPath); + + if (iconPath != null) { + Bitmap bmp = loadEntryAsBitmap(zip, iconPath); + if (bmp != null) return bmp; + Log.w(TAG, "Icon path resolved but bitmap failed: " + iconPath); + } + + // Fallback scan — some old mods don't declare icon in metadata + for (String fallback : new String[]{"pack.png", "icon.png", "logo.png"}) { + Bitmap bmp = loadEntryAsBitmap(zip, fallback); + if (bmp != null) return bmp; + } + } catch (Exception e) { + Log.w(TAG, "Failed to open JAR: " + jarFile.getName() + " — " + e.getMessage()); + } + return null; + } + + @Nullable + private static String resolveIconPath(ZipFile zip) { + // 1. Fabric — fabric.mod.json → "icon" (string OR {"64":"path"} object) + String content = readEntry(zip, "fabric.mod.json"); + if (content != null) { + try { + JsonObject obj = JsonParser.parseString(content).getAsJsonObject(); + if (obj.has("icon")) { + JsonElement iconEl = obj.get("icon"); + if (iconEl.isJsonPrimitive()) { + return iconEl.getAsString(); + } else if (iconEl.isJsonObject()) { + // Size map e.g. {"64": "path64.png", "128": "path128.png"} + // Pick the largest available + JsonObject sizeMap = iconEl.getAsJsonObject(); + String best = null; + int bestSize = 0; + for (String key : sizeMap.keySet()) { + try { + int sz = Integer.parseInt(key); + if (sz > bestSize) { + bestSize = sz; + best = sizeMap.get(key).getAsString(); + } + } catch (NumberFormatException ignored) { + best = sizeMap.get(key).getAsString(); + } + } + if (best != null) return best; + } + } + } catch (Exception e) { + Log.w(TAG, "fabric.mod.json parse error: " + e.getMessage()); + } + } + + // 2. Quilt — quilt.mod.json → quilt_loader.metadata.icon + content = readEntry(zip, "quilt.mod.json"); + if (content != null) { + try { + JsonObject root = JsonParser.parseString(content).getAsJsonObject(); + JsonObject ql = root.has("quilt_loader") ? + root.getAsJsonObject("quilt_loader") : null; + if (ql != null && ql.has("metadata")) { + JsonObject meta = ql.getAsJsonObject("metadata"); + if (meta.has("icon") && meta.get("icon").isJsonPrimitive()) + return meta.get("icon").getAsString(); + } + } catch (Exception e) { + Log.w(TAG, "quilt.mod.json parse error: " + e.getMessage()); + } + } + + // 3. Forge legacy — mcmod.info → logoFile + content = readEntry(zip, "mcmod.info"); + if (content != null) { + try { + // mcmod.info is a JSON array + JsonArray arr = JsonParser.parseString(content).getAsJsonArray(); + if (arr.size() > 0 && arr.get(0).isJsonObject()) { + JsonObject mod = arr.get(0).getAsJsonObject(); + if (mod.has("logoFile")) { + String logo = mod.get("logoFile").getAsString(); + if (!logo.isEmpty()) return logo; + } + } + } catch (Exception e) { + Log.w(TAG, "mcmod.info parse error: " + e.getMessage()); + } + } + + // 4. Forge/NeoForge — TOML — logoFile = "path" + for (String toml : new String[]{"META-INF/neoforge.mods.toml", "META-INF/mods.toml"}) { + content = readEntry(zip, toml); + if (content != null) { + String logo = tomlStringField(content, "logoFile"); + if (logo != null && !logo.isEmpty()) return logo; + } + } + + return null; + } + + // ── Low-level helpers ───────────────────────────────────────────────── + + /** + * Load a ZipEntry as a Bitmap. + * IMPORTANT: BitmapFactory.decodeStream needs mark/reset support. + * ZipInputStream doesn't support it, so we buffer all bytes first. + */ + @Nullable + private static Bitmap loadEntryAsBitmap(ZipFile zip, String entryPath) { + // ZipFile.getEntry is case-sensitive — try exact then case-insensitive scan + ZipEntry entry = zip.getEntry(entryPath); + if (entry == null) { + // Case-insensitive fallback + String lower = entryPath.toLowerCase(); + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry e = entries.nextElement(); + if (e.getName().toLowerCase().equals(lower)) { + entry = e; + break; + } + } + } + if (entry == null) return null; + + try (InputStream is = zip.getInputStream(entry)) { + // Buffer into byte array — BitmapFactory needs mark/reset + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int read; + while ((read = is.read(buf)) != -1) baos.write(buf, 0, read); + byte[] bytes = baos.toByteArray(); + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + } catch (Exception e) { + Log.w(TAG, "loadEntryAsBitmap failed for " + entryPath + ": " + e.getMessage()); + return null; + } + } + + @Nullable + private static String readEntry(ZipFile zip, String entryPath) { + ZipEntry entry = zip.getEntry(entryPath); + if (entry == null) return null; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(zip.getInputStream(entry), "UTF-8"))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) sb.append(line).append('\n'); + return sb.toString(); + } catch (Exception e) { + return null; + } + } + + @Nullable + private static String tomlStringField(String toml, String field) { + for (String line : toml.split("\n")) { + line = line.trim(); + if (line.startsWith(field + " ") || line.startsWith(field + "=")) { + int eq = line.indexOf('='); + if (eq < 0) continue; + String val = line.substring(eq + 1).trim(); + if (val.startsWith("\"") && val.endsWith("\"")) + val = val.substring(1, val.length() - 1); + if (!val.isEmpty()) return val; + } + } + return null; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java new file mode 100644 index 0000000000..86021ce927 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java @@ -0,0 +1,84 @@ +package net.kdt.pojavlaunch.modloaders; + +import androidx.annotation.NonNull; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.fragments.NeoForgeInstallFragment; +import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; + +public class NeoForgeDownloadTask implements Runnable, Tools.DownloaderFeedback { + private final String mDownloadUrl; + private final String mLoaderVersion; + + private final ModloaderDownloadListener mListener; + + public NeoForgeDownloadTask(ModloaderDownloadListener listener, @NonNull String loaderVersion) { + this.mListener = listener; + this.mDownloadUrl = String.format(NEOFORGE_INSTALLER_URL, loaderVersion); + this.mLoaderVersion = loaderVersion; + } + + private static final String NEOFORGE_INSTALLER_URL = "https://maven.neoforged.net/releases/net/neoforged/neoforge/%1$s/neoforge-%1$s-installer.jar"; + + @Override + public void run() { + if(determineDownloadUrl()) { + downloadNeoForge(); + } + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + } + + @Override + public void updateProgress(int curr, int max) { + int progress100 = (int)(((float)curr / (float)max)*100f); + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, progress100, R.string.forge_dl_progress, mLoaderVersion); + } + + private void downloadNeoForge() { + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_progress, mLoaderVersion); + try { + File destinationFile = new File(Tools.DIR_CACHE, "neoforge-installer.jar"); + byte[] buffer = new byte[8192]; + DownloadUtils.downloadFileMonitored(mDownloadUrl, destinationFile, buffer, this); + mListener.onDownloadFinished(destinationFile); + }catch (FileNotFoundException e) { + mListener.onDataNotAvailable(); + } catch (IOException e) { + mListener.onDownloadError(e); + } + } + + public boolean determineDownloadUrl() { + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.neoforge_dl_searching); + try { + if(!findVersion()) { + mListener.onDataNotAvailable(); + return false; + } + }catch (IOException e) { + mListener.onDownloadError(e); + return false; + } + return true; + } + + public boolean findVersion() throws IOException { + List neoforgeVersions = NeoForgeInstallFragment.downloadNeoForgeVersions(); + if(neoforgeVersions == null) return false; + for(String versionName : neoforgeVersions) { + if(!versionName.startsWith(mLoaderVersion)) continue; + return true; + } + return false; + } + +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java new file mode 100644 index 0000000000..df7db75b1e --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java @@ -0,0 +1,118 @@ +package net.kdt.pojavlaunch.modloaders; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.ExpandableListAdapter; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class NeoForgeVersionListAdapter extends BaseExpandableListAdapter implements ExpandableListAdapter { + private final List mGameVersions; + private final List> mNeoForgeVersions; + private final LayoutInflater mLayoutInflater; + + + public NeoForgeVersionListAdapter(List neoforgeVersions, LayoutInflater layoutInflater) { + this.mLayoutInflater = layoutInflater; + mGameVersions = new ArrayList<>(); + mNeoForgeVersions = new ArrayList<>(); + for(String version : neoforgeVersions) { + String[] parts = version.split("\\."); + String gameVersion; + try { + if (Integer.parseInt(parts[1]) < 25) { // Actual logic for normal mcvers + gameVersion = "1." + parts[0] + "." + parts[1]; + } else gameVersion = parts[0] + "." + parts[1]; + } catch (NumberFormatException ignored) { + // Handling for april fools version + gameVersion = parts[0] + "." + parts[1]; + } + List versionList; + int gameVersionIndex = mGameVersions.indexOf(gameVersion); + if(gameVersionIndex != -1) versionList = mNeoForgeVersions.get(gameVersionIndex); + else { + versionList = new ArrayList<>(); + mGameVersions.add(gameVersion); + mNeoForgeVersions.add(versionList); + } + versionList.add(version); + } + // Make it latest to oldest, top to down. + Collections.reverse(mGameVersions); + Collections.reverse(mNeoForgeVersions); + for (List mNeoForgeVersion : mNeoForgeVersions){ + Collections.reverse(mNeoForgeVersion); + } + } + @Override + public int getGroupCount() { + return mGameVersions.size(); + } + + @Override + public int getChildrenCount(int i) { + return mNeoForgeVersions.get(i).size(); + } + + @Override + public Object getGroup(int i) { + return getGameVersion(i); + } + + @Override + public Object getChild(int i, int i1) { + return getNeoForgeVersion(i, i1); + } + + @Override + public long getGroupId(int i) { + return i; + } + + @Override + public long getChildId(int i, int i1) { + return i1; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public View getGroupView(int i, boolean b, View convertView, ViewGroup viewGroup) { + if(convertView == null) + convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false); + + ((TextView) convertView).setText(getGameVersion(i)); + + return convertView; + } + + @Override + public View getChildView(int i, int i1, boolean b, View convertView, ViewGroup viewGroup) { + if(convertView == null) + convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false); + ((TextView) convertView).setText(getNeoForgeVersion(i, i1)); + return convertView; + } + + private String getGameVersion(int i) { + return mGameVersions.get(i); + } + + private String getNeoForgeVersion(int i, int i1){ + return mNeoForgeVersions.get(i).get(i1); + } + + @Override + public boolean isChildSelectable(int i, int i1) { + return true; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java index 91e396ac3f..8cba39414a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch.modloaders; +import android.app.Activity; + import com.kdt.mcgui.ProgressLayout; import net.kdt.pojavlaunch.JMinecraftVersionList; @@ -22,11 +24,13 @@ public class OptiFineDownloadTask implements Runnable, Tools.DownloaderFeedback, private final ModloaderDownloadListener mListener; private final Object mMinecraftDownloadLock = new Object(); private Throwable mDownloaderThrowable; + private final Activity activity; - public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, ModloaderDownloadListener mListener) { + public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, ModloaderDownloadListener mListener, Activity activity) { this.mOptiFineVersion = mOptiFineVersion; this.mDestinationFile = new File(Tools.DIR_CACHE, "optifine-installer.jar"); this.mListener = mListener; + this.activity = activity; } @Override @@ -89,7 +93,7 @@ public boolean downloadMinecraft(String minecraftVersion) { if(minecraftJsonVersion == null) return false; try { synchronized (mMinecraftDownloadLock) { - new MinecraftDownloader().start(null, minecraftJsonVersion, minecraftVersion, this); + new MinecraftDownloader().start(activity, minecraftJsonVersion, minecraftVersion, this); mMinecraftDownloadLock.wait(); } }catch (InterruptedException e) { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java index cbd43bd304..7383e2c2b3 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java @@ -1,21 +1,32 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.app.Activity; +import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult; +import org.jdom2.IllegalDataException; + +import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.Future; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; /** * Group all apis under the same umbrella, as another layer of abstraction @@ -112,6 +123,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE return getModpackApi(modDetail.apiSource).installMod(modDetail, selectedVersion); } + @Override + public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException { + return getModpackApi(activity, zipUri).importModpack(activity, zipUri); + } + private @NonNull ModpackApi getModpackApi(int apiSource) { switch (apiSource) { case Constants.SOURCE_MODRINTH: @@ -123,6 +139,31 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE } } + private @NonNull ModpackApi getModpackApi(Activity activity, Uri zipUri){ + String modrinthPackInfoFileName = "modrinth.index.json"; + String curseforgePackInfoFileName = "manifest.json"; + InputStream inputStream = null; + try { + inputStream = activity.getContentResolver().openInputStream(zipUri); + ZipInputStream zipInputStream = new ZipInputStream(inputStream); + ZipEntry zipEntry; + boolean isModrinth; + boolean isCurseforge; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + isModrinth = zipEntry.getName().equals(modrinthPackInfoFileName); + isCurseforge = zipEntry.getName().equals(curseforgePackInfoFileName); + if(isModrinth) { + return mModrinthApi; + } else if (isCurseforge) { + return mCurseforgeApi; + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + throw new IllegalArgumentException("Zip provided does not contain a manifest file"); + } + /** Fuse the arrays in a way that's fair for every endpoint */ private ModItem[] buildFusedResponse(List modMatrix){ int totalSize = 0; diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java index 940e4452a4..908d987eb9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java @@ -1,5 +1,7 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.app.Activity; +import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; @@ -25,6 +27,7 @@ import java.io.File; import java.io.IOException; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.regex.Pattern; @@ -61,6 +64,17 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous params.put("sortOrder", "desc"); if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty()) params.put("gameVersion", searchFilters.mcVersion); + if(searchFilters.modLoader != null && !searchFilters.modLoader.isEmpty()) { + // CF modLoaderType: 1=Forge, 4=Fabric, 5=Quilt, 6=NeoForge + int modLoaderType = 0; + switch(searchFilters.modLoader.toLowerCase()) { + case "forge": modLoaderType = 1; break; + case "fabric": modLoaderType = 4; break; + case "quilt": modLoaderType = 5; break; + case "neoforge": modLoaderType = 6; break; + } + if(modLoaderType != 0) params.put("modLoaderType", modLoaderType); + } if(previousPageResult != null) params.put("index", curseforgeSearchResult.previousOffset); @@ -75,16 +89,33 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous JsonElement allowModDistribution = dataElement.get("allowModDistribution"); // Gson automatically casts null to false, which leans to issues // So, only check the distribution flag if it is non-null - if(!allowModDistribution.isJsonNull() && !allowModDistribution.getAsBoolean()) { + boolean restricted = !allowModDistribution.isJsonNull() && !allowModDistribution.getAsBoolean(); + // For modpacks, skip restricted entries entirely (same as before) + // For individual mods, keep them so we can show the CF website dialog + if (restricted && searchFilters.isModpack) { Log.i("CurseforgeApi", "Skipping modpack "+dataElement.get("name").getAsString() + " because curseforge sucks"); continue; } + JsonObject logo = dataElement.getAsJsonObject("logo"); + String thumbnailUrl = (logo != null && logo.has("thumbnailUrl") && !logo.get("thumbnailUrl").isJsonNull()) + ? logo.get("thumbnailUrl").getAsString() : ""; ModItem modItem = new ModItem(Constants.SOURCE_CURSEFORGE, searchFilters.isModpack, dataElement.get("id").getAsString(), dataElement.get("name").getAsString(), dataElement.get("summary").getAsString(), - dataElement.getAsJsonObject("logo").get("thumbnailUrl").getAsString()); + thumbnailUrl); + modItem.isRestricted = restricted; + // Capture the mod page URL from CF API for use in restriction dialog + JsonObject links = dataElement.getAsJsonObject("links"); + if (links != null && links.has("websiteUrl") && !links.get("websiteUrl").isJsonNull()) { + modItem.websiteUrl = links.get("websiteUrl").getAsString(); + } else { + // Fallback using slug if available, otherwise numeric id + String slug = dataElement.has("slug") && !dataElement.get("slug").isJsonNull() + ? dataElement.get("slug").getAsString() : modItem.id; + modItem.websiteUrl = "https://www.curseforge.com/minecraft/mc-mods/" + slug; + } modItemList.add(modItem); } if(curseforgeSearchResult == null) curseforgeSearchResult = new CurseforgeSearchResult(); @@ -97,6 +128,18 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous @Override public ModDetail getModDetails(ModItem item) { + return getModDetails(item, null); + } + + public ModDetail getModDetails(ModItem item, String filterMcVersion) { + // Short-circuit for restricted mods — no point fetching versions + if (item.isRestricted) { + return new ModDetail(item, + new String[]{"Blocked by the author!"}, + new String[]{null}, + new String[]{null}, + new String[]{null}); + } ArrayList allModDetails = new ArrayList<>(); int index = 0; while(index != CURSEFORGE_PAGINATION_END_REACHED && @@ -104,7 +147,43 @@ public ModDetail getModDetails(ModItem item) { index = getPaginatedDetails(allModDetails, index, item.id); } if(index == CURSEFORGE_PAGINATION_ERROR) return null; + + // Filter by MC version if specified + if (filterMcVersion != null && !filterMcVersion.isEmpty()) { + ArrayList filtered = new ArrayList<>(); + for (JsonObject v : allModDetails) { + JsonArray gameVersions = v.getAsJsonArray("gameVersions"); + for (JsonElement el : gameVersions) { + if (filterMcVersion.equals(el.getAsString())) { + filtered.add(v); + break; + } + } + } + allModDetails = filtered; + } + int length = allModDetails.size(); + + // Check if ALL versions have null downloadUrl (fully restricted mod) + boolean allRestricted = length > 0; + for (int i = 0; i < length; i++) { + JsonElement url = allModDetails.get(i).get("downloadUrl"); + if (url != null && !url.isJsonNull()) { + allRestricted = false; + break; + } + } + if (allRestricted || length == 0) { + // All versions restricted - return a ModDetail with one entry that signals this. + // The version name shown in spinner makes it clear, tapping Install shows CF dialog. + return new ModDetail(item, + new String[]{"Blocked by the author! By clicking the install button it will open the CurseForge page."}, + new String[]{null}, + new String[]{null}, + new String[]{null}); + } + String[] versionNames = new String[length]; String[] mcVersionNames = new String[length]; String[] versionUrls = new String[length]; @@ -114,7 +193,11 @@ public ModDetail getModDetails(ModItem item) { versionNames[i] = modDetail.get("displayName").getAsString(); JsonElement downloadUrl = modDetail.get("downloadUrl"); - versionUrls[i] = downloadUrl.getAsString(); + if (downloadUrl == null || downloadUrl.isJsonNull()) { + versionUrls[i] = null; + } else { + versionUrls[i] = downloadUrl.getAsString(); + } JsonArray gameVersions = modDetail.getAsJsonArray("gameVersions"); for(JsonElement jsonElement : gameVersions) { @@ -137,6 +220,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE return ModpackInstaller.installModpack(modDetail, selectedVersion, this::installCurseforgeZip); } + @Override + public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException { + return ModpackInstaller.importModpack(activity, zipUri, this::installCurseforgeZip); + } + private int getPaginatedDetails(ArrayList objectList, int index, String modId) { HashMap params = new HashMap<>(); @@ -211,6 +299,9 @@ private ModLoader createInfo(CurseManifest.CurseMinecraft minecraft) { case "fabric": modLoaderTypeInt = ModLoader.MOD_LOADER_FABRIC; break; + case "neoforge": + modLoaderTypeInt = ModLoader.MOD_LOADER_NEOFORGE; + break; default: return null; //TODO: Quilt is also Forge? How does that work? @@ -271,4 +362,4 @@ private boolean verifyManifest(CurseManifest manifest) { static class CurseforgeSearchResult extends SearchResult { int previousOffset; } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java index 1eef3567b4..59f1dd28a1 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java @@ -9,6 +9,7 @@ import net.kdt.pojavlaunch.modloaders.ForgeDownloadTask; import net.kdt.pojavlaunch.modloaders.ForgeUtils; import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener; +import net.kdt.pojavlaunch.modloaders.NeoForgeDownloadTask; import java.io.File; @@ -16,6 +17,7 @@ public class ModLoader { public static final int MOD_LOADER_FORGE = 0; public static final int MOD_LOADER_FABRIC = 1; public static final int MOD_LOADER_QUILT = 2; + public static final int MOD_LOADER_NEOFORGE = 3; public final int modLoaderType; public final String modLoaderVersion; public final String minecraftVersion; @@ -38,6 +40,8 @@ public String getVersionId() { return "fabric-loader-"+modLoaderVersion+"-"+minecraftVersion; case MOD_LOADER_QUILT: return "quilt-loader-"+modLoaderVersion+"-"+minecraftVersion; + case MOD_LOADER_NEOFORGE: + return "neoforge-"+modLoaderVersion; default: return null; } @@ -57,6 +61,8 @@ public Runnable getDownloadTask(ModloaderDownloadListener listener) { return createFabriclikeTask(listener, FabriclikeUtils.FABRIC_UTILS); case MOD_LOADER_QUILT: return createFabriclikeTask(listener, FabriclikeUtils.QUILT_UTILS); + case MOD_LOADER_NEOFORGE: + return new NeoForgeDownloadTask(listener, modLoaderVersion); default: return null; } @@ -77,6 +83,11 @@ public Intent getInstallationIntent(Context context, File modInstallerJar) { case MOD_LOADER_FORGE: ForgeUtils.addAutoInstallArgs(baseIntent, modInstallerJar, getVersionId()); return baseIntent; + case MOD_LOADER_NEOFORGE: + return baseIntent + .putExtra("javaArgs", "-jar "+modInstallerJar.getAbsolutePath()+" --install-client") + .putExtra("openLogOutput", true) + ; case MOD_LOADER_QUILT: case MOD_LOADER_FABRIC: default: @@ -91,6 +102,7 @@ public Intent getInstallationIntent(Context context, File modInstallerJar) { public boolean requiresGuiInstallation() { switch (modLoaderType) { case MOD_LOADER_FORGE: + case MOD_LOADER_NEOFORGE: return true; case MOD_LOADER_FABRIC: case MOD_LOADER_QUILT: diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java index 141468af88..a6ea9c8dca 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java @@ -1,7 +1,9 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.app.Activity; import android.content.Context; +import android.net.Uri; import com.kdt.mcgui.ProgressLayout; @@ -12,8 +14,12 @@ import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult; +import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; import java.io.IOException; +import java.io.File; +import java.security.NoSuchAlgorithmException; + /** * @@ -70,4 +76,13 @@ default void handleInstallation(Context context, ModDetail modDetail, int select * @param selectedVersion The selected version */ ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOException; + + /** + * Imports the mod(pack) from a file. + * May require the download of additional files. + * May requires launching the installation of a modloader + * @param activity any activity + * @param zipUri URI to DocumentsUI selected zip file + */ + ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException; } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java index 048458d30a..0c614b8ca7 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java @@ -1,5 +1,10 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.app.Activity; +import android.net.Uri; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.kdt.mcgui.ProgressLayout; import net.kdt.pojavlaunch.R; @@ -11,10 +16,20 @@ import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; + +import java.io.BufferedReader; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Locale; import java.util.concurrent.Callable; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; public class ModpackInstaller { @@ -69,7 +84,94 @@ public static ModLoader installModpack(ModDetail modDetail, int selectedVersion, return modLoaderInfo; } - interface InstallFunction { + public static ModLoader importModpack(Activity activity, Uri zipUri, InstallFunction installFunction) throws IOException, NoSuchAlgorithmException { + String modrinthPackInfoFileName = "modrinth.index.json"; + String curseforgePackInfoFileName = "manifest.json"; + InputStream inputStream = null; + inputStream = activity.getContentResolver().openInputStream(zipUri); + ZipInputStream zipInputStream = new ZipInputStream(inputStream); + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + boolean isModrinth = zipEntry.getName().equals(modrinthPackInfoFileName); + boolean isCurseforge = zipEntry.getName().equals(curseforgePackInfoFileName); + if (!(isModrinth || isCurseforge)) continue; + // Read Manifest JSON + BufferedReader reader = new BufferedReader(new InputStreamReader(zipInputStream)); + String str; + StringBuilder jsonString = new StringBuilder(); + while ((str = reader.readLine()) != null) { + jsonString.append(str).append("\n"); + } + zipInputStream.close(); + + // Hash the ZIP File + inputStream = activity.getContentResolver().openInputStream(zipUri); + MessageDigest algorithm = MessageDigest.getInstance("SHA-1"); + DigestInputStream hashingStream = new DigestInputStream(inputStream, algorithm); + + byte[] buffer = new byte[8192]; + while (hashingStream.read(buffer) != -1) {} // just read to update the digest + hashingStream.close(); + byte[] digest = algorithm.digest(); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + String hash = sb.toString(); + + // Parse the JSON to prepare for instance creation + JsonObject packInfoJson = JsonParser.parseString(jsonString.toString()).getAsJsonObject(); + String modpackName; + if(isModrinth){ + // Added a for because there is an awkward __ that I can't be bothered to fix + // FO only deduplication be like: + modpackName = (packInfoJson.get("name").getAsString().toLowerCase(Locale.ROOT) + + packInfoJson.get("versionId") + "for" + + packInfoJson.get("dependencies").getAsJsonObject().get("minecraft")); + } else { + modpackName = (packInfoJson.get("name").getAsString().toLowerCase(Locale.ROOT) + + packInfoJson.get("version") + "for" + + packInfoJson.get("minecraft").getAsJsonObject().get("version")); + } + modpackName = modpackName.trim().replaceAll("[\\\\/:*?\"<>| \\t\\n]", "_"); + modpackName = modpackName + hash; + + // Copy ZIP file to cache + if(modpackName == null) throw new IOException("Corrupt Modpack manifest file."); + File modpackFile = null; + modpackFile = new File(Tools.DIR_CACHE, modpackName + ".cf"); + inputStream = activity.getContentResolver().openInputStream(zipUri); + FileOutputStream output = new FileOutputStream(modpackFile); + byte[] b = new byte[4 * 1024]; + int read; + while ((read = inputStream.read(b)) != -1) { + output.write(b, 0, read); + } + output.flush(); + + // Install the actual pack into custom_instances + ModLoader modLoaderInfo = installFunction.installModpack(modpackFile, new File(Tools.DIR_GAME_HOME, "custom_instances/"+modpackName)); + // We have to do this because installModpack doesn't clean up after itself + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + modpackFile.delete(); + if(modLoaderInfo == null) { + return null; + } + + // Create the instance (We don't have a picture guys) + MinecraftProfile profile = new MinecraftProfile(); + profile.gameDir = "./custom_instances/" + modpackName; + profile.name = packInfoJson.get("name").getAsString(); + profile.lastVersionId = modLoaderInfo.getVersionId(); + LauncherProfiles.mainProfileJson.profiles.put(modpackName, profile); + LauncherProfiles.write(); + + return modLoaderInfo; + } + throw new IOException("Can't find manifest file in modpack provided"); +} + +interface InstallFunction { ModLoader installModpack(File modpackFile, File instanceDestination) throws IOException; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java index 73a5863f6e..78e48fc10f 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java @@ -1,5 +1,8 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.app.Activity; +import android.net.Uri; + import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.kdt.mcgui.ProgressLayout; @@ -17,6 +20,7 @@ import java.io.File; import java.io.IOException; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Map; import java.util.zip.ZipFile; @@ -48,6 +52,8 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous facetString.append(String.format("[\"project_type:%s\"]", searchFilters.isModpack ? "modpack" : "mod")); if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty()) facetString.append(String.format(",[\"versions:%s\"]", searchFilters.mcVersion)); + if(searchFilters.modLoader != null && !searchFilters.modLoader.isEmpty()) + facetString.append(String.format(",[\"categories:%s\"]", searchFilters.modLoader)); facetString.append("]"); params.put("facets", facetString.toString()); params.put("query", searchFilters.name); @@ -82,32 +88,88 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous @Override public ModDetail getModDetails(ModItem item) { + return getModDetails(item, null, null); + } + public ModDetail getModDetails(ModItem item, String filterMcVersion) { + return getModDetails(item, filterMcVersion, null); + } + + public ModDetail getModDetails(ModItem item, String filterMcVersion, String filterLoader) { JsonArray response = mApiHandler.get(String.format("project/%s/version", item.id), JsonArray.class); if(response == null) return null; - System.out.println(response); - String[] names = new String[response.size()]; - String[] mcNames = new String[response.size()]; - String[] urls = new String[response.size()]; - String[] hashes = new String[response.size()]; - - for (int i=0; i versions = new java.util.ArrayList<>(); + for (int i = 0; i < response.size(); i++) { + JsonObject v = response.get(i).getAsJsonObject(); + if (filterMcVersion != null && !filterMcVersion.isEmpty()) { + JsonArray gameVersions = v.get("game_versions").getAsJsonArray(); + boolean matches = false; + for (int j = 0; j < gameVersions.size(); j++) { + if (filterMcVersion.equals(gameVersions.get(j).getAsString())) { + matches = true; + break; + } + } + if (!matches) continue; + } + if (filterLoader != null && !filterLoader.isEmpty()) { + JsonArray loaders = v.get("loaders").getAsJsonArray(); + boolean matches = false; + for (int j = 0; j < loaders.size(); j++) { + if (filterLoader.equalsIgnoreCase(loaders.get(j).getAsString())) { + matches = true; + break; + } + } + if (!matches) continue; + } + versions.add(v); + } + + if (versions.isEmpty()) return null; + + int size = versions.size(); + String[] names = new String[size]; + String[] mcNames = new String[size]; + String[] urls = new String[size]; + String[] hashes = new String[size]; + String[][] depIds = new String[size][]; + String[][] depTypes = new String[size][]; + + for (int i = 0; i < size; i++) { + JsonObject version = versions.get(i); + names[i] = version.get("name").getAsString(); mcNames[i] = version.get("game_versions").getAsJsonArray().get(0).getAsString(); - urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString(); - // Assume there may not be hashes, in case the API changes + urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString(); + JsonObject hashesMap = version.getAsJsonArray("files").get(0).getAsJsonObject() .get("hashes").getAsJsonObject(); - if(hashesMap == null || hashesMap.get("sha1") == null){ - hashes[i] = null; - continue; + hashes[i] = (hashesMap == null || hashesMap.get("sha1") == null) ? null + : hashesMap.get("sha1").getAsString(); + + // Capture dependencies + if (version.has("dependencies") && !version.get("dependencies").isJsonNull()) { + JsonArray deps = version.getAsJsonArray("dependencies"); + java.util.List ids = new java.util.ArrayList<>(); + java.util.List types = new java.util.ArrayList<>(); + for (int j = 0; j < deps.size(); j++) { + JsonObject dep = deps.get(j).getAsJsonObject(); + if (dep.has("project_id") && !dep.get("project_id").isJsonNull()) { + ids.add(dep.get("project_id").getAsString()); + types.add(dep.has("dependency_type") ? dep.get("dependency_type").getAsString() : "required"); + } + } + depIds[i] = ids.toArray(new String[0]); + depTypes[i] = types.toArray(new String[0]); + } else { + depIds[i] = new String[0]; + depTypes[i] = new String[0]; } - - hashes[i] = hashesMap.get("sha1").getAsString(); } - return new ModDetail(item, names, mcNames, urls, hashes); + return new ModDetail(item, names, mcNames, urls, hashes, depIds, depTypes); } @Override @@ -116,6 +178,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE return ModpackInstaller.installModpack(modDetail, selectedVersion, this::installMrpack); } + @Override + public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException { + return ModpackInstaller.importModpack(activity, zipUri, this::installMrpack); + } + private static ModLoader createInfo(ModrinthIndex modrinthIndex) { if(modrinthIndex == null) return null; Map dependencies = modrinthIndex.dependencies; @@ -131,6 +198,9 @@ private static ModLoader createInfo(ModrinthIndex modrinthIndex) { if((modLoaderVersion = dependencies.get("quilt-loader")) != null) { return new ModLoader(ModLoader.MOD_LOADER_QUILT, modLoaderVersion, mcVersion); } + if((modLoaderVersion = dependencies.get("neoforge")) != null) { + return new ModLoader(ModLoader.MOD_LOADER_NEOFORGE, modLoaderVersion, mcVersion); + } return null; } @@ -156,4 +226,4 @@ private ModLoader installMrpack(File mrpackFile, File instanceDestination) throw class ModrinthSearchResult extends SearchResult { int previousOffset; } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java index 5a54e78b75..d080a2669a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java @@ -12,16 +12,30 @@ public class ModDetail extends ModItem { public String[] versionUrls; /* SHA 1 hashes, null if a hash is unavailable */ public String[] versionHashes; + /* Per-version dependency project IDs */ + public String[][] versionDependencyIds; + /* Per-version dependency types — "required" or "optional" */ + public String[][] versionDependencyTypes; + public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls, String[] hashes) { + this(item, versionNames, mcVersionNames, versionUrls, hashes, null, null); + } + + public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls, String[] hashes, + String[][] depIds, String[][] depTypes) { super(item.apiSource, item.isModpack, item.id, item.title, item.description, item.imageUrl); + this.isRestricted = item.isRestricted; + this.websiteUrl = item.websiteUrl; this.versionNames = versionNames; this.mcVersionNames = mcVersionNames; this.versionUrls = versionUrls; this.versionHashes = hashes; + this.versionDependencyIds = depIds; + this.versionDependencyTypes = depTypes; // Add the mc version to the version model for (int i=0; i installJvmLauncher) { mDialogView = new RecyclerView(activity); mDialogView.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)); RTRecyclerViewAdapter adapter = new RTRecyclerViewAdapter(); + adapter.setDialog(get()); mDialogView.setAdapter(adapter); mDialog = new AlertDialog.Builder(activity) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java index 09e602cd4a..e09ff738bf 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java @@ -1,5 +1,6 @@ package net.kdt.pojavlaunch.multirt; +import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture; import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR; import static org.apache.commons.io.FileUtils.listFiles; @@ -8,6 +9,8 @@ import com.kdt.mcgui.ProgressLayout; +import net.kdt.pojavlaunch.Architecture; +import net.kdt.pojavlaunch.NewJREUtil.ExternalRuntime; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.utils.MathUtils; @@ -36,7 +39,7 @@ public class MultiRTUtils { private static final String JAVA_VERSION_STR = "JAVA_VERSION=\""; private static final String OS_ARCH_STR = "OS_ARCH=\""; - public static List getRuntimes() { + public static List getInstalledRuntimes() { if(!RUNTIME_FOLDER.exists() && !RUNTIME_FOLDER.mkdirs()) { throw new RuntimeException("Failed to create runtime directory"); } @@ -51,8 +54,25 @@ public static List getRuntimes() { return runtimes; } + /** + * + * @return Java versions which are not installed but are present in {@link ExternalRuntime} + */ + public static List getRuntimesToDownload() { + List runtimesToDownload = new ArrayList<>(); + ExternalRuntime[] downloadableRuntimes = ExternalRuntime.values(); + for (ExternalRuntime downloadableruntime : downloadableRuntimes) { + if(getExactJreName(downloadableruntime.majorVersion) == null){ + // x86 isn't supported anymore for JRE25 + if (!(getDeviceArchitecture() == Architecture.ARCH_X86 && downloadableruntime.majorVersion >= 21)) + runtimesToDownload.add(downloadableruntime); + } + } + return runtimesToDownload; + } + public static String getExactJreName(int majorVersion) { - List runtimes = getRuntimes(); + List runtimes = getInstalledRuntimes(); for(Runtime r : runtimes) if(r.javaVersion == majorVersion)return r.name; @@ -60,7 +80,7 @@ public static String getExactJreName(int majorVersion) { } public static String getNearestJreName(int majorVersion) { - List runtimes = getRuntimes(); + List runtimes = getInstalledRuntimes(); MathUtils.RankedValue nearestRankedRuntime = MathUtils.findNearestPositive(majorVersion, runtimes, (runtime)->runtime.javaVersion); if(nearestRankedRuntime == null) return null; Runtime nearestRuntime = nearestRankedRuntime.value; diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java index 02e4072e2f..00efae87e9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java @@ -18,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView; import net.kdt.pojavlaunch.Architecture; +import net.kdt.pojavlaunch.NewJREUtil; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.prefs.LauncherPreferences; @@ -28,6 +29,7 @@ public class RTRecyclerViewAdapter extends RecyclerView.Adapter { private boolean mIsDeleting = false; + private MultiRTConfigDialog dialog; @NonNull @Override @@ -38,13 +40,18 @@ public RTViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) @Override public void onBindViewHolder(@NonNull RTViewHolder holder, int position) { - final List runtimes = MultiRTUtils.getRuntimes(); - holder.bindRuntime(runtimes.get(position),position); + final List installedRuntimes = MultiRTUtils.getInstalledRuntimes(); + final List downloadableRuntimes = MultiRTUtils.getRuntimesToDownload(); + if (installedRuntimes.size() > position) { + holder.bindInstalledRuntime(installedRuntimes.get(position),position); + } else if (installedRuntimes.size() + downloadableRuntimes.size() > position) { + holder.bindDownloadableRuntime(downloadableRuntimes.get(position - installedRuntimes.size()), position); + } } @Override public int getItemCount() { - return MultiRTUtils.getRuntimes().size(); + return MultiRTUtils.getInstalledRuntimes().size() + MultiRTUtils.getRuntimesToDownload().size(); } public boolean isDefaultRuntime(Runtime rt) { @@ -68,6 +75,10 @@ public boolean getIsEditing(){ return mIsDeleting; } + public void setDialog(MultiRTConfigDialog multiRTConfigDialog) { + this.dialog = multiRTConfigDialog; + } + public class RTViewHolder extends RecyclerView.ViewHolder { final TextView mJavaVersionTextView; @@ -104,7 +115,7 @@ private void setupOnClickListeners(){ mDeleteButton.setOnClickListener(v -> { if (mCurrentRuntime == null) return; - if(MultiRTUtils.getRuntimes().size() < 2) { + if(MultiRTUtils.getInstalledRuntimes().size() < 2) { new AlertDialog.Builder(mContext) .setTitle(R.string.global_error) .setMessage(R.string.multirt_config_removeerror_last) @@ -129,7 +140,7 @@ private void setupOnClickListeners(){ }); } - public void bindRuntime(Runtime runtime, int pos) { + public void bindInstalledRuntime(Runtime runtime, int pos) { mCurrentRuntime = runtime; mCurrentPosition = pos; if(runtime.versionString != null && Tools.DEVICE_ARCHITECTURE == Architecture.archAsInt(runtime.arch)) { @@ -159,6 +170,48 @@ public void bindRuntime(Runtime runtime, int pos) { mSetDefaultButton.setVisibility(View.GONE); } + @SuppressLint("NotifyDataSetChanged") + public void bindDownloadableRuntime(NewJREUtil.ExternalRuntime runtime, int pos) { + mCurrentPosition = pos; + mJavaVersionTextView.setText(runtime.name + .replace(".tar.xz", "") + .replace("-", " ")); + mFullJavaVersionTextView.setText(R.string.global_not_installed); + mFullJavaVersionTextView.setTextColor(mDefaultColors); + mSetDefaultButton.setVisibility(View.VISIBLE); + mDeleteButton.setVisibility(View.GONE); + + if (runtime.isDownloading) { + mSetDefaultButton.setEnabled(false); + mSetDefaultButton.setText(R.string.global_installing); + } else { + mSetDefaultButton.setEnabled(true); + mSetDefaultButton.setText(R.string.global_download); + } + + mSetDefaultButton.setOnClickListener(v -> { + runtime.isDownloading = true; + mSetDefaultButton.setEnabled(false); + mSetDefaultButton.setText(R.string.global_download); + sExecutorService.execute(() -> { + mSetDefaultButton.setText(R.string.global_installing); + try { + runtime.downloadRuntime(v.getContext()); + } catch (RuntimeException e) { + Tools.showErrorRemote(e); + } + v.post(() -> { + // Reset the listener for this button so SET DEFAULT actually sets default + setupOnClickListeners(); + // Update the UI so it knows it got installed + notifyDataSetChanged(); + runtime.isDownloading = false; + }); + }); + }); + + } + private void updateButtonsVisibility(){ mSetDefaultButton.setVisibility(mIsDeleting ? View.GONE : View.VISIBLE); mDeleteButton.setVisibility(mIsDeleting ? View.VISIBLE : View.GONE); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java new file mode 100644 index 0000000000..0cd0538679 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java @@ -0,0 +1,56 @@ +package net.kdt.pojavlaunch.plugins; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.util.Log; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +public class LibraryPlugin { + private static final String TAG = "LibraryPlugin"; + + // Known plugins constants + public static final String ID_ANGLE_PLUGIN = "git.mojo.angle"; + public static final String ID_FFMPEG_PLUGIN = "git.mojo.ffmpeg"; + + private String appId; + private String libraryPath; + private LibraryPlugin(String app, String libraryPath){ + this.appId = app; + this.libraryPath = libraryPath; + } + public static LibraryPlugin discoverPlugin(Context ctx, String appId){ + + String libraryPath; + try { + PackageInfo pluginPackage = ctx.getPackageManager().getPackageInfo(appId, PackageManager.GET_SHARED_LIBRARY_FILES); + libraryPath = pluginPackage.applicationInfo.nativeLibraryDir; + + } catch (Exception e){ + Log.e(TAG, "Plugin discover failed: " + e.getMessage()); + return null; + } + return new LibraryPlugin(appId, libraryPath); + } + + public String getId(){ + return appId; + } + + public String getLibraryPath(){ + return libraryPath; + } + public String resolveAbsolutePath(String library) { + return new File(libraryPath, library).getAbsolutePath(); + } + + public boolean checkLibraries(String... libs){ + for(String lib : libs){ + if(!(new File(libraryPath, lib).exists())) return false; + } + return true; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java new file mode 100644 index 0000000000..b276122316 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java @@ -0,0 +1,16 @@ +package net.kdt.pojavlaunch.prefs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +public class CustomListSummaryProvider implements Preference.SummaryProvider { + @Nullable + public CharSequence provideSummary(@NonNull Preference preference) { + if (preference.hasKey()) + preference.setSummary(preference.getKey()); + else preference.setSummary("@string/mcl_setting_title_renderer_settings"); + return null; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java index e7796c34a4..424cfe3db0 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java @@ -15,16 +15,18 @@ import net.kdt.pojavlaunch.*; import net.kdt.pojavlaunch.multirt.MultiRTUtils; +import net.kdt.pojavlaunch.utils.FileUtils; import net.kdt.pojavlaunch.utils.JREUtils; +import java.io.File; import java.io.IOException; +import java.util.LinkedHashMap; public class LauncherPreferences { public static final String PREF_KEY_CURRENT_PROFILE = "currentProfile"; public static final String PREF_KEY_SKIP_NOTIFICATION_CHECK = "skipNotificationPermissionCheck"; public static SharedPreferences DEFAULT_PREF; - public static String PREF_RENDERER = "opengles2"; public static boolean PREF_IGNORE_NOTCH = false; public static int PREF_NOTCH_SIZE = 0; @@ -37,6 +39,8 @@ public class LauncherPreferences { public static final String PREF_VERSION_REPOS = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; public static boolean PREF_CHECK_LIBRARY_SHA = true; public static boolean PREF_DISABLE_GESTURES = false; + public static boolean PREF_GAMEPAD_SDL_PASSTHRU = false; + public static boolean PREF_GAMEPAD_FORCEDSDL_PASSTHRU = false; public static boolean PREF_DISABLE_SWAP_HAND = false; public static float PREF_MOUSESPEED = 1f; public static int PREF_RAM_ALLOCATION; @@ -67,6 +71,10 @@ public class LauncherPreferences { public static String PREF_DOWNLOAD_SOURCE = "default"; public static boolean PREF_SKIP_NOTIFICATION_PERMISSION_CHECK = false; public static boolean PREF_VSYNC_IN_ZINK = true; + public static boolean PREF_FORCE_ENABLE_TOUCHCONTROLLER = false; + public static int PREF_TOUCHCONTROLLER_VIBRATE_LENGTH = 100; + + public static boolean PREF_MOUSE_GRAB_FORCE = false; public static void loadPreferences(Context ctx) { @@ -74,7 +82,6 @@ public static void loadPreferences(Context ctx) { Tools.initStorageConstants(ctx); boolean isDevicePowerful = isDevicePowerful(ctx); - PREF_RENDERER = DEFAULT_PREF.getString("renderer", "opengles2"); PREF_BUTTONSIZE = DEFAULT_PREF.getInt("buttonscale", 100); PREF_MOUSESCALE = DEFAULT_PREF.getInt("mousescale", 100)/100f; PREF_MOUSESPEED = ((float)DEFAULT_PREF.getInt("mousespeed",100))/100f; @@ -84,6 +91,8 @@ public static void loadPreferences(Context ctx) { PREF_FORCE_ENGLISH = DEFAULT_PREF.getBoolean("force_english", false); PREF_CHECK_LIBRARY_SHA = DEFAULT_PREF.getBoolean("checkLibraries",true); PREF_DISABLE_GESTURES = DEFAULT_PREF.getBoolean("disableGestures",false); + PREF_GAMEPAD_SDL_PASSTHRU = DEFAULT_PREF.getBoolean("gamepadPassthru",false); + PREF_GAMEPAD_FORCEDSDL_PASSTHRU = DEFAULT_PREF.getBoolean("gamepadPassthruForced",false); PREF_DISABLE_SWAP_HAND = DEFAULT_PREF.getBoolean("disableDoubleTap", false); PREF_RAM_ALLOCATION = DEFAULT_PREF.getInt("allocation", findBestRAMAllocation(ctx)); PREF_CUSTOM_JAVA_ARGS = DEFAULT_PREF.getString("javaArgs", ""); @@ -109,6 +118,9 @@ public static void loadPreferences(Context ctx) { PREF_VERIFY_MANIFEST = DEFAULT_PREF.getBoolean("verifyManifest", true); PREF_SKIP_NOTIFICATION_PERMISSION_CHECK = DEFAULT_PREF.getBoolean(PREF_KEY_SKIP_NOTIFICATION_CHECK, false); PREF_VSYNC_IN_ZINK = DEFAULT_PREF.getBoolean("vsync_in_zink", true); + PREF_FORCE_ENABLE_TOUCHCONTROLLER = DEFAULT_PREF.getBoolean("forceEnableTouchController", false); + PREF_TOUCHCONTROLLER_VIBRATE_LENGTH = DEFAULT_PREF.getInt("touchControllerVibrateLength", 100); + PREF_MOUSE_GRAB_FORCE = DEFAULT_PREF.getBoolean("always_grab_mouse", false); String argLwjglLibname = "-Dorg.lwjgl.opengl.libname="; for (String arg : JREUtils.parseJavaArguments(PREF_CUSTOM_JAVA_ARGS)) { @@ -121,11 +133,11 @@ public static void loadPreferences(Context ctx) { if(DEFAULT_PREF.contains("defaultRuntime")) { PREF_DEFAULT_RUNTIME = DEFAULT_PREF.getString("defaultRuntime",""); }else{ - if(MultiRTUtils.getRuntimes().isEmpty()) { + if(MultiRTUtils.getInstalledRuntimes().isEmpty()) { PREF_DEFAULT_RUNTIME = ""; return; } - PREF_DEFAULT_RUNTIME = MultiRTUtils.getRuntimes().get(0).name; + PREF_DEFAULT_RUNTIME = MultiRTUtils.getInstalledRuntimes().get(0).name; LauncherPreferences.DEFAULT_PREF.edit().putString("defaultRuntime",LauncherPreferences.PREF_DEFAULT_RUNTIME).apply(); } } @@ -216,4 +228,39 @@ public static void computeNotchSize(Activity activity) { } Tools.updateWindowSize(activity); } -} + public static void writeMGRendererSettings() throws IOException { + LinkedHashMap MGConfigJson = new LinkedHashMap<>(); + // Copying the defaultValues from pref_renderer.xml to use as defaults here too + + // We need to get the string and convert it to int because the android:defaultValues only takes in string-arrays. + // Using .getInt() leads to a class cast exception and using integer-arrays will just crash the layout/fragment. + MGConfigJson.put("enableANGLE", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_angle", "0"))); + MGConfigJson.put("enableNoError", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_errorSetting", "0"))); + MGConfigJson.put("fsr1Setting", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_fsr", "0"))); + + // These guys are SwitchPreferences so they get special treatment, they need to be converted to ints + int gl43exts = DEFAULT_PREF.getBoolean("mg_renderer_setting_gl43ext", false) ? 1 : 0; + int computeShaderext = DEFAULT_PREF.getBoolean("mg_renderer_computeShaderext", false) ? 1 : 0; + int angleDepthClearFixMode = DEFAULT_PREF.getBoolean("mg_renderer_setting_angleDepthClearFixMode", false) ? 1 : 0; + int timerQueryExt = DEFAULT_PREF.getBoolean("mg_renderer_setting_timerQueryExt", false) ? 1 : 0; + int dsaExt = DEFAULT_PREF.getBoolean("mg_renderer_dsaExt", false) ? 1 : 0; + MGConfigJson.put("enableExtGL43", gl43exts); + MGConfigJson.put("enableExtComputeShader", computeShaderext); + MGConfigJson.put("angleDepthClearFixMode", angleDepthClearFixMode); + MGConfigJson.put("enableExtTimerQuery", timerQueryExt); + MGConfigJson.put("enableExtDirectStateAccess", dsaExt); + if (DEFAULT_PREF.getBoolean("mg_renderer_multidrawCompute", false)) { + MGConfigJson.put("multidrawMode", 5); // Special handling for the (special mayhaps) compute emulation + } else MGConfigJson.put("multidrawMode", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_multidraw", "0"))); + MGConfigJson.put("maxGlslCacheSize", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_glsl_cache_size", "128"))); + File configFile = new File(Tools.DIR_DATA + "/MobileGlues", "config.json"); + FileUtils.ensureParentDirectory(configFile); + try { + Tools.write(configFile.getAbsolutePath(),Tools.GLOBAL_GSON.toJson(MGConfigJson)); + Logger.appendToLog("Writing MG configs to " + configFile.getAbsolutePath()); + Logger.appendToLog("MG Config is " + Tools.GLOBAL_GSON.toJson(MGConfigJson)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java index 5458dbf572..0689a7ce1d 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java @@ -7,6 +7,7 @@ import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_GYRO_SENSITIVITY; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_LONGPRESS_TRIGGER; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSESPEED; +import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE; import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_SCALE_FACTOR; import android.annotation.SuppressLint; @@ -31,11 +32,11 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { private SharedPreferences.Editor mEditor; @SuppressLint("UseSwitchCompatOrMaterialCode") - private Switch mGyroSwitch, mGyroXSwitch, mGyroYSwitch, mGestureSwitch; + private Switch mGyroSwitch, mGyroXSwitch, mGyroYSwitch, mGestureSwitch, mMouseGrabSwitch; private CustomSeekbar mGyroSensitivityBar, mMouseSpeedBar, mGestureDelayBar, mResolutionBar; private TextView mGyroSensitivityText, mGyroSensitivityDisplayText, mMouseSpeedText, mGestureDelayText, mGestureDelayDisplayText, mResolutionText; - private boolean mOriginalGyroEnabled, mOriginalGyroXEnabled, mOriginalGyroYEnabled, mOriginalGestureDisabled; + private boolean mOriginalGyroEnabled, mOriginalGyroXEnabled, mOriginalGyroYEnabled, mOriginalGestureDisabled, mOriginalMouseGrab; private float mOriginalGyroSensitivity, mOriginalMouseSpeed, mOriginalResolution; private int mOriginalGestureDelay; @@ -65,6 +66,7 @@ private void bindLayout() { mGyroXSwitch = mDialogContent.findViewById(R.id.checkboxGyroX); mGyroYSwitch = mDialogContent.findViewById(R.id.checkboxGyroY); mGestureSwitch = mDialogContent.findViewById(R.id.checkboxGesture); + mMouseGrabSwitch = mDialogContent.findViewById(R.id.always_grab_mouse_side_dialog); mGyroSensitivityBar = mDialogContent.findViewById(R.id.editGyro_seekbar); mMouseSpeedBar = mDialogContent.findViewById(R.id.editMouseSpeed_seekbar); @@ -86,6 +88,7 @@ private void setupListeners() { mOriginalGyroXEnabled = PREF_GYRO_INVERT_X; mOriginalGyroYEnabled = PREF_GYRO_INVERT_Y; mOriginalGestureDisabled = PREF_DISABLE_GESTURES; + mOriginalMouseGrab = PREF_MOUSE_GRAB_FORCE; mOriginalGyroSensitivity = PREF_GYRO_SENSITIVITY; mOriginalMouseSpeed = PREF_MOUSESPEED; @@ -96,6 +99,7 @@ private void setupListeners() { mGyroXSwitch.setChecked(mOriginalGyroXEnabled); mGyroYSwitch.setChecked(mOriginalGyroYEnabled); mGestureSwitch.setChecked(mOriginalGestureDisabled); + mMouseGrabSwitch.setChecked(mOriginalMouseGrab); mGyroSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { PREF_ENABLE_GYRO = isChecked; @@ -122,6 +126,11 @@ private void setupListeners() { mEditor.putBoolean("disableGestures", isChecked); }); + mMouseGrabSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + PREF_MOUSE_GRAB_FORCE = isChecked; + mEditor.putBoolean("always_grab_mouse", isChecked); + }); + mGyroSensitivityBar.setOnSeekBarChangeListener((SimpleSeekBarListener) (seekBar, progress, fromUser) -> { PREF_GYRO_SENSITIVITY = progress / 100f; mEditor.putInt("gyroSensitivity", progress); @@ -156,6 +165,7 @@ private void setupListeners() { setSeekTextPercent(mResolutionText, mResolutionBar.getProgress()); + updateMouseGrabVisibility(); updateGyroVisibility(mOriginalGyroEnabled); updateGestureVisibility(mOriginalGestureDisabled); } @@ -172,6 +182,10 @@ private static void setSeekText(TextView target, int format, int value) { target.setText(target.getContext().getString(format, value)); } + private void updateMouseGrabVisibility(){ + mMouseGrabSwitch.setVisibility(Tools.isPointerDeviceConnected()? View.VISIBLE : View.GONE); + } + private void updateGyroVisibility(boolean isEnabled) { int visibility = isEnabled ? View.VISIBLE : View.GONE; mGyroXSwitch.setVisibility(visibility); @@ -202,6 +216,7 @@ private void removeListeners() { mGyroXSwitch.setOnCheckedChangeListener(null); mGyroYSwitch.setOnCheckedChangeListener(null); mGestureSwitch.setOnCheckedChangeListener(null); + mMouseGrabSwitch.setOnCheckedChangeListener(null); mGyroSensitivityBar.setOnSeekBarChangeListener(null); mMouseSpeedBar.setOnSeekBarChangeListener(null); @@ -225,6 +240,7 @@ public void cancel() { PREF_GYRO_INVERT_X = mOriginalGyroXEnabled; PREF_GYRO_INVERT_Y = mOriginalGyroYEnabled; PREF_DISABLE_GESTURES = mOriginalGestureDisabled; + PREF_MOUSE_GRAB_FORCE = mOriginalMouseGrab; PREF_GYRO_SENSITIVITY = mOriginalGyroSensitivity; PREF_MOUSESPEED = mOriginalMouseSpeed; diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java index 7002f84526..bc793cba03 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java @@ -13,6 +13,7 @@ public class LauncherPreferenceControlFragment extends LauncherPreferenceFragment { private boolean mGyroAvailable = false; + @Override public void onCreatePreferences(Bundle b, String str) { // Get values @@ -20,6 +21,7 @@ public void onCreatePreferences(Bundle b, String str) { int prefButtonSize = (int) LauncherPreferences.PREF_BUTTONSIZE; int mouseScale = (int) (LauncherPreferences.PREF_MOUSESCALE * 100); int gyroSampleRate = LauncherPreferences.PREF_GYRO_SAMPLE_RATE; + int touchControllerVibrateLength = LauncherPreferences.PREF_TOUCHCONTROLLER_VIBRATE_LENGTH; float mouseSpeed = LauncherPreferences.PREF_MOUSESPEED; float gyroSpeed = LauncherPreferences.PREF_GYRO_SENSITIVITY; float joystickDeadzone = LauncherPreferences.PREF_DEADZONE_SCALE; @@ -45,7 +47,7 @@ public void onCreatePreferences(Bundle b, String str) { CustomSeekBarPreference seek6 = requirePreference("mousespeed", CustomSeekBarPreference.class); - seek6.setValue((int)(mouseSpeed *100f)); + seek6.setValue((int) (mouseSpeed * 100f)); seek6.setSuffix(" %"); CustomSeekBarPreference deadzoneSeek = requirePreference("gamepad_deadzone_scale", @@ -55,22 +57,29 @@ public void onCreatePreferences(Bundle b, String str) { Context context = getContext(); - if(context != null) { + if (context != null) { mGyroAvailable = Tools.deviceSupportsGyro(context); } - PreferenceCategory gyroCategory = requirePreference("gyroCategory", + PreferenceCategory gyroCategory = requirePreference("gyroCategory", PreferenceCategory.class); gyroCategory.setVisible(mGyroAvailable); CustomSeekBarPreference gyroSensitivitySeek = requirePreference("gyroSensitivity", CustomSeekBarPreference.class); - gyroSensitivitySeek.setValue((int) (gyroSpeed*100f)); + gyroSensitivitySeek.setValue((int) (gyroSpeed * 100f)); gyroSensitivitySeek.setSuffix(" %"); CustomSeekBarPreference gyroSampleRateSeek = requirePreference("gyroSampleRate", CustomSeekBarPreference.class); gyroSampleRateSeek.setValue(gyroSampleRate); gyroSampleRateSeek.setSuffix(" ms"); + + CustomSeekBarPreference touchControllerVibrateLengthSeek = requirePreference( + "touchControllerVibrateLength", + CustomSeekBarPreference.class); + touchControllerVibrateLengthSeek.setValue(touchControllerVibrateLength); + touchControllerVibrateLengthSeek.setSuffix(" ms"); + computeVisibility(); } @@ -80,7 +89,7 @@ public void onSharedPreferenceChanged(SharedPreferences p, String s) { computeVisibility(); } - private void computeVisibility(){ + private void computeVisibility() { requirePreference("timeLongPressTrigger").setVisible(!LauncherPreferences.PREF_DISABLE_GESTURES); requirePreference("gyroSensitivity").setVisible(LauncherPreferences.PREF_ENABLE_GYRO); requirePreference("gyroSampleRate").setVisible(LauncherPreferences.PREF_ENABLE_GYRO); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java index 6b5ea17b87..83b3e5711b 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java @@ -1,13 +1,143 @@ package net.kdt.pojavlaunch.prefs.screens; +import android.app.AlertDialog; +import android.net.Uri; import android.os.Bundle; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.fragments.MainMenuFragment; +import net.kdt.pojavlaunch.fragments.RightPaneHomeFragment; +import net.kdt.pojavlaunch.theme.ThemeManager; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; +import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; public class LauncherPreferenceExperimentalFragment extends LauncherPreferenceFragment { + private final ActivityResultLauncher mImagePickerLauncher = + registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> { + if (uri != null) copyImageToBgFile(uri); + }); + @Override public void onCreatePreferences(Bundle b, String str) { addPreferencesFromResource(R.xml.pref_experimental); + setupForceLandscape(); + setupCustomBackground(); + setupColourTheme(); + } + + // ── Force landscape ─────────────────────────────────────────────────────── + + private void setupForceLandscape() { + SwitchPreferenceCompat pref = requirePreference("force_landscape", SwitchPreferenceCompat.class); + pref.setOnPreferenceChangeListener((preference, newValue) -> { + boolean force = Boolean.TRUE.equals(newValue); + requireActivity().setRequestedOrientation( + force ? SCREEN_ORIENTATION_SENSOR_LANDSCAPE : SCREEN_ORIENTATION_UNSPECIFIED); + return true; + }); + + SwitchPreferenceCompat gradientPref = requirePreference("enable_bg_gradient", SwitchPreferenceCompat.class); + gradientPref.setOnPreferenceChangeListener((preference, newValue) -> { + // Save explicitly before recreate — the framework saves after listener returns + net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF.edit() + .putBoolean(ThemeManager.KEY_GRADIENT, Boolean.TRUE.equals(newValue)) + .commit(); // commit() not apply() — must be synchronous before recreate + requireActivity().recreate(); + return true; + }); + } + + // ── Custom background ───────────────────────────────────────────────────── + + private void setupCustomBackground() { + requirePreference("set_custom_launcher_bg").setOnPreferenceClickListener(p -> { + mImagePickerLauncher.launch("image/*"); + return true; + }); + + requirePreference("remove_custom_launcher_bg").setOnPreferenceClickListener(p -> { + File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH); + if (bgFile.exists()) bgFile.delete(); + notifyHomeFragmentBgChanged(); + toast(R.string.preference_custom_bg_removed); + return true; + }); + } + + private void copyImageToBgFile(@NonNull Uri uri) { + File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH); + try (InputStream in = requireContext().getContentResolver().openInputStream(uri); + OutputStream out = new FileOutputStream(bgFile)) { + if (in == null) throw new Exception("Cannot open URI"); + byte[] buf = new byte[8192]; + int len; + while ((len = in.read(buf)) != -1) out.write(buf, 0, len); + notifyHomeFragmentBgChanged(); + toast(R.string.preference_custom_bg_set_success); + } catch (Exception e) { + if (bgFile.exists()) bgFile.delete(); + toast(R.string.preference_custom_bg_error); + } + } + + // ── Colour theme ────────────────────────────────────────────────────────── + + private void setupColourTheme() { + // Button 1: preset picker + reset + requirePreference("colour_theme_presets").setOnPreferenceClickListener(p -> { + showPresetDialog(); + return true; + }); + } + + private void showPresetDialog() { + ThemeManager.Preset[] presets = ThemeManager.PRESETS; + String[] labels = new String[presets.length + 1]; + for (int i = 0; i < presets.length; i++) labels[i] = presets[i].name; + labels[presets.length] = getString(R.string.preference_colour_reset); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.preference_colour_presets_title) + .setItems(labels, (dialog, which) -> { + if (which < presets.length) { + ThemeManager.applyPreset(presets[which]); + } else { + ThemeManager.resetToDefault(); + } + requireActivity().recreate(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void toast(int resId) { + Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show(); + } + + private void notifyHomeFragmentBgChanged() { + MainMenuFragment mmf = (MainMenuFragment) requireActivity() + .getSupportFragmentManager() + .findFragmentByTag("ROOT"); + if (mmf == null) return; + RightPaneHomeFragment home = (RightPaneHomeFragment) mmf + .getChildFragmentManager() + .findFragmentByTag(RightPaneHomeFragment.TAG); + if (home != null) home.reloadBackground(); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java index 7275f86912..e46bc1b875 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java @@ -23,7 +23,7 @@ public class LauncherPreferenceFragment extends PreferenceFragmentCompat impleme @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - view.setBackgroundColor(getResources().getColor(R.color.background_app)); + net.kdt.pojavlaunch.theme.ThemeManager.applyToPrefView(view); super.onViewCreated(view, savedInstanceState); } @@ -35,6 +35,7 @@ public void onCreatePreferences(Bundle b, String str) { private void setupNotificationRequestPreference() { Preference mRequestNotificationPermissionPreference = requirePreference("notification_permission_request"); + Preference mMicrophonePermissionPreference = requirePreference("microphone_permission_request"); Activity activity = getActivity(); if(activity instanceof LauncherActivity) { LauncherActivity launcherActivity = (LauncherActivity)activity; @@ -43,6 +44,11 @@ private void setupNotificationRequestPreference() { launcherActivity.askForNotificationPermission(()->mRequestNotificationPermissionPreference.setVisible(false)); return true; }); + mMicrophonePermissionPreference.setVisible(!launcherActivity.checkForMicrophonePermission()); + mMicrophonePermissionPreference.setOnPreferenceClickListener(preference -> { + launcherActivity.askForMicrophonePermission(()->mMicrophonePermissionPreference.setVisible(false)); + return true; + }); }else{ mRequestNotificationPermissionPreference.setVisible(false); } @@ -78,4 +84,4 @@ protected T requirePreference(CharSequence key, Class if(preferenceClass.isInstance(preference)) return (T)preference; throw new IllegalStateException("Preference "+key+" is not an instance of "+preferenceClass.getSimpleName()); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java index 975ec8e089..344630cdff 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java @@ -7,7 +7,10 @@ import android.widget.TextView; import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; import androidx.preference.EditTextPreference; +import androidx.preference.Preference; +import androidx.preference.SwitchPreference; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; @@ -18,11 +21,20 @@ public class LauncherPreferenceJavaFragment extends LauncherPreferenceFragment { private MultiRTConfigDialog mDialogScreen; + private SwitchPreference mSwitchAutoJRE; private final ActivityResultLauncher mVmInstallLauncher = registerForActivityResult(new OpenDocumentWithExtension("xz"), (data)->{ if(data != null) Tools.installRuntimeFromUri(getContext(), data); }); + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mSwitchAutoJRE = findPreference("disable_autojre_select"); + mSwitchAutoJRE.setSummary("Stops automatic selection of which runtime to use in \"" + getString(R.string.main_install_jar_file) + "\""); + + } + @Override public void onCreatePreferences(Bundle b, String str) { int ramAllocation = LauncherPreferences.PREF_RAM_ALLOCATION; diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java new file mode 100644 index 0000000000..63a25e543e --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java @@ -0,0 +1,95 @@ +package net.kdt.pojavlaunch.prefs.screens; + +import static android.text.InputType.TYPE_CLASS_NUMBER; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; + +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.SwitchPreference; + +import net.kdt.pojavlaunch.R; + +import java.util.Objects; + +public class LauncherPreferenceRendererSettingsFragment extends LauncherPreferenceFragment { + EditTextPreference GLSLCachePreference; + ListPreference MultiDrawEmulationPreference; + SwitchPreference ComputeMultiDrawPreference; + Preference.SummaryProvider MultiDrawSummaryProvider; + + @Override + public void onCreatePreferences(Bundle b, String str) { + addPreferencesFromResource(R.xml.pref_renderer); + GLSLCachePreference = findPreference("mg_renderer_setting_glsl_cache_size"); + ComputeMultiDrawPreference = findPreference("mg_renderer_multidrawCompute"); + MultiDrawEmulationPreference = findPreference("mg_renderer_setting_multidraw"); + GLSLCachePreference.setOnBindEditTextListener((editText) -> { + editText.setInputType(TYPE_CLASS_NUMBER); + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // Nothing, its boilerplate + } + + @Override + public void afterTextChanged(Editable editable) { + // Nothing, its boilerplate + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // This is just to handle the summary not updating when its above max int value + // Horrible I know. + if (editText.getText().toString().isEmpty()) { + editText.setText("0"); + } + if (Long.parseLong(editText.getText().toString()) > Integer.MAX_VALUE) { + editText.setError("Too big! Setting to maximum value"); + editText.setText(String.valueOf(Integer.MAX_VALUE)); + } + + } + }); + }); + updateGLSLCacheSummary(); // Just updates the summary with the value when user opens the menu. Yes it's out of place. + updateMultiDrawSummary(); // Same as above + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences p, String s) { + GLSLCachePreference = findPreference("mg_renderer_setting_glsl_cache_size"); + updateGLSLCacheSummary(); + updateMultiDrawSummary(); + } + + private void updateMultiDrawSummary() { + if (MultiDrawEmulationPreference != null) { + if (MultiDrawEmulationPreference.getSummaryProvider() != null) { + MultiDrawSummaryProvider = MultiDrawEmulationPreference.getSummaryProvider(); + } + if (ComputeMultiDrawPreference.isChecked()) { + MultiDrawEmulationPreference.setEnabled(false); + MultiDrawEmulationPreference.setSummaryProvider(null); + MultiDrawEmulationPreference.setSummary("(Experimental) Compute"); + } else if (MultiDrawEmulationPreference != null) { + MultiDrawEmulationPreference.setEnabled(true); + MultiDrawEmulationPreference.setSummaryProvider(MultiDrawSummaryProvider); + } + } + } + + private void updateGLSLCacheSummary() { + try { + if (Objects.equals(Objects.requireNonNull(this.GLSLCachePreference).getText(), "") || Integer.parseInt(Objects.requireNonNull(this.GLSLCachePreference.getText())) == 0) { + this.GLSLCachePreference.setSummary(getString(R.string.global_off)); + } else this.GLSLCachePreference.setSummary(this.GLSLCachePreference.getText() + " MB"); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java index 14f412e513..c17bfd4171 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java @@ -45,12 +45,6 @@ public void onCreatePreferences(Bundle b, String str) { requirePreference("alternate_surface", SwitchPreferenceCompat.class).setChecked(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE); requirePreference("force_vsync", SwitchPreferenceCompat.class).setChecked(LauncherPreferences.PREF_FORCE_VSYNC); - ListPreference rendererListPreference = requirePreference("renderer", - ListPreference.class); - Tools.RenderersList renderersList = Tools.getCompatibleRenderers(getContext()); - rendererListPreference.setEntries(renderersList.rendererDisplayNames); - rendererListPreference.setEntryValues(renderersList.rendererIds.toArray(new String[0])); - computeVisibility(); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java index 70a01ae017..01293980b3 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java @@ -93,6 +93,11 @@ public void setViewProfile(View v, String nm, boolean displaySelection) { MinecraftProfile minecraftProfile = mProfiles.get(nm); if(minecraftProfile == null) minecraftProfile = dummy; Drawable cachedIcon = ProfileIconCache.fetchIcon(v.getResources(), nm, minecraftProfile.icon); + // Explicitly set bounds to match ExtendedTextView's drawableStartSize (_36sdp). + // Without this, BitmapDrawable uses raw pixel dimensions causing size inconsistency + // after profile edits (when the icon cache is dropped and re-fetched). + int iconSize = (int) v.getResources().getDimension(R.dimen._36sdp); + cachedIcon.setBounds(0, 0, iconSize, iconSize); extendedTextView.setCompoundDrawablesRelative(cachedIcon, null, extendedTextView.getCompoundsDrawables()[2], null); // Historically, the profile name "New" was hardcoded as the default profile name @@ -147,4 +152,4 @@ public void reloadProfiles(ProfileAdapterExtra[] extraEntries) { else mExtraEntires = extraEntries; this.reloadProfiles(); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java new file mode 100644 index 0000000000..52a3a9ab4b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java @@ -0,0 +1,16 @@ +package net.kdt.pojavlaunch.render; + +import android.content.Context; +import android.view.Surface; +import android.view.View; + +public interface SurfaceProvider{ + View create(Context context, SurfaceCallback callback); + void updateSize(); + + interface SurfaceCallback { + void onSurfaceAvailable(Surface surface); + void onSurfaceResized(); + void onSurfaceDestroyed(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java new file mode 100644 index 0000000000..4a1acd0bf2 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java @@ -0,0 +1,54 @@ +package net.kdt.pojavlaunch.render; + +import static net.kdt.pojavlaunch.CallbackBridge.windowHeight; +import static net.kdt.pojavlaunch.CallbackBridge.windowWidth; + +import android.content.Context; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; + +import androidx.annotation.NonNull; + +import net.kdt.pojavlaunch.CallbackBridge; + +public class SurfaceViewSurfaceProvider implements SurfaceProvider { + private SurfaceView mSurfaceView; + @Override + public View create(Context context, SurfaceCallback callback) { + mSurfaceView = new SurfaceView(context); + mSurfaceView.getHolder().addCallback(new CallbackAdapter(callback)); + if(windowWidth != 0 && windowHeight != 0) + mSurfaceView.getHolder().setFixedSize(windowWidth, windowHeight); + return mSurfaceView; + } + + @Override + public void updateSize() { + mSurfaceView.getHolder().setFixedSize(windowWidth, windowHeight); + } + + private static class CallbackAdapter implements SurfaceHolder.Callback { + private final SurfaceCallback mCallback; + + private CallbackAdapter(SurfaceCallback mCallback) { + this.mCallback = mCallback; + } + + @Override + public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int fmt, int width, int height) { + mCallback.onSurfaceResized(); + } + + @Override + public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) { + mCallback.onSurfaceAvailable(surfaceHolder.getSurface()); + } + + @Override + public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) { + mCallback.onSurfaceDestroyed(); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java new file mode 100644 index 0000000000..99173b15f9 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java @@ -0,0 +1,64 @@ +package net.kdt.pojavlaunch.render; + +import static net.kdt.pojavlaunch.CallbackBridge.windowHeight; +import static net.kdt.pojavlaunch.CallbackBridge.windowWidth; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; + +import androidx.annotation.NonNull; + +import net.kdt.pojavlaunch.Tools; + +public class TextureViewSurfaceProvider implements SurfaceProvider { + private TextureView mTextureView; + private SurfaceCallback mCallback; + + @Override + public View create(Context context, SurfaceCallback callback) { + mCallback = callback; + mTextureView = new TextureView(context); + mTextureView.setOpaque(true); + mTextureView.setAlpha(1.0f); + mTextureView.setSurfaceTextureListener(new CallbackAdapter()); + return mTextureView; + } + + @Override + public void updateSize() { + SurfaceTexture surfaceTexture = mTextureView.getSurfaceTexture(); + if(surfaceTexture != null) { + surfaceTexture.setDefaultBufferSize(windowWidth, windowHeight); + Tools.runOnUiThread(()->mCallback.onSurfaceResized()); + } + } + + private class CallbackAdapter implements TextureView.SurfaceTextureListener { + + @Override + public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int i, int i1) { + if(windowWidth != 0 && windowHeight != 0) + surfaceTexture.setDefaultBufferSize(windowWidth, windowHeight); + mCallback.onSurfaceAvailable(new Surface(surfaceTexture)); + } + + @Override + public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) { + mCallback.onSurfaceDestroyed(); + return true; + } + + @Override + public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture, int i, int i1) { + mCallback.onSurfaceResized(); + } + + @Override + public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) { + + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java index e2cb008277..37ab66c42a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java @@ -2,6 +2,8 @@ import static net.kdt.pojavlaunch.Architecture.archAsString; +import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid; +import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture; import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; import android.content.Context; @@ -10,6 +12,7 @@ import com.kdt.mcgui.ProgressLayout; +import net.kdt.pojavlaunch.Architecture; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.multirt.MultiRTUtils; @@ -83,16 +86,52 @@ public static void unpackComponents(Context ctx){ unpackComponent(ctx, "caciocavallo17", false); // Since the Java module system doesn't allow multiple JARs to declare the same module, // we repack them to a single file here - unpackComponent(ctx, "lwjgl3", false); + unpackLwjglNatives(ctx); + unpackComponent(ctx, "lwjgl3/3.3.3", false); + unpackComponent(ctx, "lwjgl3/3.4.1", false); unpackComponent(ctx, "security", true); unpackComponent(ctx, "arc_dns_injector", true); + unpackComponent(ctx, "methods_injector_agent", true); unpackComponent(ctx, "forge_installer", true); } catch (IOException e) { - Log.e("AsyncAssetManager", "Failed o unpack components !",e ); + Log.e("AsyncAssetManager", "Failed to unpack components !",e ); } ProgressLayout.clearProgress(ProgressLayout.EXTRACT_COMPONENTS); }); } + // Piggybacks off of the java modules extracting later to use their version files for update checks + // This is indeed prone to breaking. + private static void unpackLwjglNatives(Context ctx) throws IOException { + AssetManager am = ctx.getAssets(); + String rootDir = Tools.DIR_DATA; + String sArch = archAsStringAndroid(getDeviceArchitecture()); + + String[] lwjglVersions = {"3.3.3", "3.4.1"}; + for (String lwjglVer : lwjglVersions) { + File versionFile = new File(Tools.DIR_GAME_HOME + String.format("/lwjgl3/%s/version", lwjglVer)); + InputStream is = am.open("components/lwjgl3/" + lwjglVer + "/version"); + String pathToLwjglNatives = String.format("lwjgl-%s-natives/", lwjglVer) + sArch; + + boolean shouldUpdate = true; + if (versionFile.exists()) { + FileInputStream fis = new FileInputStream(versionFile); + String release1 = Tools.read(is); + String release2 = Tools.read(fis); + if (release1.equals(release2)) + shouldUpdate = false; + } + + if (shouldUpdate) { + Log.i("UnpackLwjgl", lwjglVer + " was installed manually, or does not exist, unpacking new..."); + String[] fileList = am.list("components/" + pathToLwjglNatives); + for (String fileName : fileList) { + Tools.copyAssetFile(ctx, "components/" + pathToLwjglNatives + "/" + fileName, rootDir + "/" + pathToLwjglNatives, true); + } + } else { + Log.i("UnpackLwjgl", lwjglVer + " is up-to-date with the launcher, continuing..."); + } + } + } private static void unpackComponent(Context ctx, String component, boolean privateDirectory) throws IOException { AssetManager am = ctx.getAssets(); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java index 0a18ac0fbb..3238a4ffdd 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java @@ -26,7 +26,7 @@ public class AsyncVersionList { public void getVersionList(@Nullable VersionDoneListener listener, boolean secondPass){ sExecutorService.execute(() -> { - File versionFile = new File(Tools.DIR_DATA + "/version_list.json"); + File versionFile = new File(Tools.DIR_CACHE + "/version_list.json"); JMinecraftVersionList versionList = null; try{ if(!versionFile.exists() || (System.currentTimeMillis() > versionFile.lastModified() + 86400000 )){ @@ -68,7 +68,7 @@ private JMinecraftVersionList downloadVersionList(String mirror){ // Then save the version list //TODO make it not save at times ? - FileOutputStream fos = new FileOutputStream(Tools.DIR_DATA + "/version_list.json"); + FileOutputStream fos = new FileOutputStream(Tools.DIR_CACHE + "/version_list.json"); fos.write(jsonString.getBytes()); fos.close(); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java index 1c7bc7fbee..83471c833e 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java @@ -3,6 +3,9 @@ import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; import android.app.Activity; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.util.Log; import androidx.annotation.NonNull; @@ -25,7 +28,11 @@ import net.kdt.pojavlaunch.value.MinecraftClientInfo; import net.kdt.pojavlaunch.value.MinecraftLibraryArtifact; +import java.io.BufferedReader; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; import java.util.Map; @@ -54,6 +61,9 @@ public class MinecraftDownloader { private static final ThreadLocal sThreadLocalDownloadBuffer = new ThreadLocal<>(); + private boolean isLocalProfile = false; + private boolean isOnline; + /** * Start the game version download process on the global executor service. * @param activity Activity, used for automatic installation of JRE 17 if needed @@ -62,12 +72,46 @@ public class MinecraftDownloader { * @param listener The download status listener */ public void start(@Nullable Activity activity, @Nullable JMinecraftVersionList.Version version, - @NonNull String realVersion, // this was there for a reason + @NonNull String realVersion, @NonNull AsyncMinecraftDownloader.DoneListener listener) { + if(activity != null){ + isLocalProfile = Tools.isLocalProfile(activity); + isOnline = Tools.isOnline(activity); + Tools.switchDemo(Tools.isDemoProfile(activity)); + + } else { + isLocalProfile = true; + Tools.switchDemo(true); + } + sExecutorService.execute(() -> { try { + if(isLocalProfile || !isOnline) { + String versionMessage = realVersion; // Use provided version unless we find its a modded instance + + // See if provided version is a modded version and if that version depends on another jar, check for presence of both jar's .json. + try { + // This reads the .json associated with the provided version. If it fails, we can assume it's not installed. + File providedJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + realVersion + "/" + realVersion + ".json"); + JMinecraftVersionList.Version providedJson = Tools.GLOBAL_GSON.fromJson(Tools.read(providedJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class); + + // This checks if running modded version that depends on other jars, so we use that for the error message. + File vanillaJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + providedJson.inheritsFrom + "/" + providedJson.inheritsFrom + ".json"); + versionMessage = providedJson.inheritsFrom != null ? providedJson.inheritsFrom : versionMessage; + + // Ensure they're both not some 0 byte corrupted json + if (providedJsonFile.length() == 0 || vanillaJsonFile.exists() && vanillaJsonFile.length() == 0){ + throw new RuntimeException("Minecraft "+versionMessage+ " is needed by " +realVersion); } + + listener.onDownloadDone(); + } catch (Exception e) { + String tryagain = !isOnline ? "Please ensure you have an internet connection" : "Please try again on your Microsoft Account"; + Tools.showErrorRemote(versionMessage + " is not currently installed. "+ tryagain, e); + } + }else { downloadGame(activity, version, realVersion); listener.onDownloadDone(); + } }catch (Exception e) { listener.onDownloadFailed(e); } @@ -97,9 +141,7 @@ private void downloadGame(Activity activity, JMinecraftVersionList.Version verIn mDownloaderThreadException = new AtomicReference<>(null); mUseFileCounter = false; - if(!downloadAndProcessMetadata(activity, verInfo, versionName)) { - throw new RuntimeException(activity.getString(R.string.exception_failed_to_unpack_jre17)); - } + downloadAndProcessMetadata(activity, verInfo, versionName); ArrayBlockingQueue taskQueue = new ArrayBlockingQueue<>(mScheduledDownloadTasks.size(), false); @@ -249,7 +291,7 @@ private boolean downloadAndProcessMetadata(Activity activity, JMinecraftVersionL } if(activity != null && !NewJREUtil.installNewJreIfNeeded(activity, verInfo)){ - return false; + throw new RuntimeException(activity.getString(R.string.exception_failed_to_unpack_jre17)); } JAssets assets = downloadAssetsIndex(verInfo); @@ -456,18 +498,49 @@ private String downloadSha1() throws IOException { * Since Minecraft libraries are stored in maven repositories, try to use * this when downloading libraries without hashes in the json. */ - private void tryGetLibrarySha1() { + private void tryGetLibrarySha1() throws IOException { + File sha1CacheDir = new File(Tools.DIR_CACHE + "/sha1hashes"); + File cacheFile = new File(sha1CacheDir.getAbsolutePath() + FileUtils.getFileName(mTargetUrl) + ".sha"); + + // Only use cache when its offline. No point in having cache invalidation now! + if (!isOnline || !LauncherPreferences.PREF_CHECK_LIBRARY_SHA) { // Well not only offlines..this setting speeds up launch times at least! + try (BufferedReader cacheFileReader = new BufferedReader(new FileReader(cacheFile))) { + mTargetSha1 = cacheFileReader.readLine(); + if (mTargetSha1 != null) { + Log.i("MinecraftDownloader", "Reading Hash from cache: " + mTargetSha1 + " from " + cacheFile); + } else if (cacheFile.exists()) { + Log.i("MinecraftDownloader", "Deleting invalid hash from cache: " + cacheFile); + cacheFile.delete(); + } + } catch (FileNotFoundException ignored) { + mTargetSha1 = null; + Log.w("MinecraftDownloader", "Failed to read hash for " + cacheFile); + } + return; + } + String resultHash = null; try { resultHash = downloadSha1(); // The hash is a 40-byte download. mInternetUsageCounter.getAndAdd(40); - }catch (IOException e) { + } catch (IOException e) { Log.i("MinecraftDownloader", "Failed to download hash", e); + if (cacheFile.exists() && new BufferedReader(new FileReader(cacheFile)).readLine() == null) { + Log.i("MinecraftDownloader", "Deleting failed hash download from cache: " + cacheFile); + cacheFile.delete(); + } } - if(resultHash != null) { - Log.i("MinecraftDownloader", "Got hash: "+resultHash+ " for "+FileUtils.getFileName(mTargetUrl)); + if (resultHash != null) { + Log.i("MinecraftDownloader", "Got hash: " + resultHash + " for " + FileUtils.getFileName(mTargetUrl)); mTargetSha1 = resultHash; + if (!sha1CacheDir.exists()) { + sha1CacheDir.mkdir(); // If mkdir() fails, something went wrong with initializing /data/data/. mkdirs() isn't used on purpose + } + try (FileWriter writeHash = new FileWriter(cacheFile)) { + Log.i("MinecraftDownloader", "Saving hash: " + resultHash + " for " + FileUtils.getFileName(mTargetUrl) + " to " + cacheFile); + writeHash.write(resultHash); + } } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java new file mode 100644 index 0000000000..6f469941a8 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java @@ -0,0 +1,132 @@ +package net.kdt.pojavlaunch.theme; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.palette.graphics.Palette; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.fragments.RightPaneHomeFragment; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; + +import java.io.File; + +public class ThemeManager { + + private static final String KEY_THEME = "launcher_theme"; + public static final String KEY_GRADIENT = "enable_bg_gradient"; + + public static final Preset[] PRESETS = { + new Preset("Default (Copper)", R.style.AppTheme, R.style.AppTheme_Gradient), + new Preset("Midnight Blue", R.style.AppTheme_MidnightBlue, R.style.AppTheme_MidnightBlue_Gradient), + new Preset("Forest Green", R.style.AppTheme_ForestGreen, R.style.AppTheme_ForestGreen_Gradient), + new Preset("Crimson", R.style.AppTheme_Crimson, R.style.AppTheme_Crimson_Gradient), + new Preset("Amethyst", R.style.AppTheme_Amethyst, R.style.AppTheme_Amethyst_Gradient), + new Preset("Arctic", R.style.AppTheme_Arctic, R.style.AppTheme_Arctic_Gradient), + }; + + public static void applyPreset(@NonNull Preset preset) { + LauncherPreferences.DEFAULT_PREF.edit() + .putInt(KEY_THEME, preset.styleRes) + .apply(); + } + + public static void resetToDefault() { + applyPreset(PRESETS[0]); + } + + /** + * Apply the current theme's bgMainDrawable to a preference fragment's root view. + * Called from LauncherPreferenceFragment.onViewCreated(). + */ + public static void applyToPrefView(@NonNull android.view.View view) { + android.util.TypedValue tv = new android.util.TypedValue(); + view.getContext().getTheme().resolveAttribute( + net.kdt.pojavlaunch.R.attr.bgMainDrawable, tv, true); + if (tv.type >= android.util.TypedValue.TYPE_FIRST_COLOR_INT + && tv.type <= android.util.TypedValue.TYPE_LAST_COLOR_INT) { + view.setBackgroundColor(tv.data); + } else if (tv.resourceId != 0) { + view.setBackgroundResource(tv.resourceId); + } + } + + /** + * Call in Activity.onCreate() BEFORE setContentView(). + * Returns the flat or gradient style depending on the gradient toggle. + */ + @StyleRes + public static int getSavedTheme() { + int base = LauncherPreferences.DEFAULT_PREF.getInt(KEY_THEME, R.style.AppTheme); + boolean gradient = LauncherPreferences.DEFAULT_PREF.getBoolean(KEY_GRADIENT, false); + + // Normalise: always find the matching flat preset style + // (guards against an old gradient style ID being stored in prefs) + Preset matched = PRESETS[0]; + for (Preset p : PRESETS) { + if (p.styleRes == base || p.gradientStyleRes == base) { + matched = p; + break; + } + } + + return gradient ? matched.gradientStyleRes : matched.styleRes; + } + + /** + * Use Palette API to pick the closest built-in preset from the custom background. + */ + public static boolean applyFromCustomBackground() { + File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH); + if (!bgFile.exists()) return false; + + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inSampleSize = 4; + Bitmap bmp = BitmapFactory.decodeFile(bgFile.getAbsolutePath(), opts); + if (bmp == null) return false; + + Palette palette = Palette.from(bmp).maximumColorCount(24).generate(); + bmp.recycle(); + + Palette.Swatch dominant = firstNonNull( + palette.getDarkVibrantSwatch(), + palette.getVibrantSwatch(), + palette.getDarkMutedSwatch(), + palette.getMutedSwatch() + ); + if (dominant == null) return false; + + float[] hsl = dominant.getHsl(); + float[] presetHues = { 20f, 210f, 120f, 0f, 280f, 185f }; + Preset best = PRESETS[0]; + float bestDist = Float.MAX_VALUE; + for (int i = 0; i < PRESETS.length; i++) { + float dist = Math.abs(hsl[0] - presetHues[i]); + if (dist > 180) dist = 360 - dist; + if (dist < bestDist) { bestDist = dist; best = PRESETS[i]; } + } + + applyPreset(best); + return true; + } + + @SafeVarargs + private static T firstNonNull(T... items) { + for (T t : items) if (t != null) return t; + return null; + } + + public static final class Preset { + public final String name; + public final int styleRes; + public final int gradientStyleRes; + public Preset(String name, int styleRes, int gradientStyleRes) { + this.name = name; + this.styleRes = styleRes; + this.gradientStyleRes = gradientStyleRes; + } + } +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java index dbdbadaf07..0f3bc3e962 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java @@ -15,6 +15,7 @@ @SuppressWarnings("IOStreamConstructor") public class DownloadUtils { public static final String USER_AGENT = Tools.APP_NAME; + private static final int TIME_OUT = 10000; public static void download(String url, OutputStream os) throws IOException { download(new URL(url), os); @@ -26,7 +27,8 @@ public static void download(URL url, OutputStream os) throws IOException { // System.out.println("Connecting: " + url.toString()); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestProperty("User-Agent", USER_AGENT); - conn.setConnectTimeout(10000); + conn.setConnectTimeout(TIME_OUT); + conn.setReadTimeout(TIME_OUT); conn.setDoInput(true); conn.connect(); if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) { @@ -59,6 +61,12 @@ public static void downloadFile(String url, File out) throws IOException { FileUtils.ensureParentDirectory(out); try (FileOutputStream fileOutputStream = new FileOutputStream(out)) { download(url, fileOutputStream); + } catch (IOException e) { + if (out.length() < 1) { // Only delete it if file is 0 bytes cause this file might already be downloaded and something else went wrong. + Log.i("DownloadUtils", "Cleaning up failed download: " + out.getAbsolutePath()); + out.delete(); + throw e; + } } } @@ -67,6 +75,8 @@ public static void downloadFileMonitored(String urlInput, File outputFile, @Null FileUtils.ensureParentDirectory(outputFile); HttpURLConnection conn = (HttpURLConnection) new URL(urlInput).openConnection(); + conn.setConnectTimeout(TIME_OUT); + conn.setReadTimeout(TIME_OUT); InputStream readStr = conn.getInputStream(); try (FileOutputStream fos = new FileOutputStream(outputFile)) { int current; @@ -81,8 +91,9 @@ public static void downloadFileMonitored(String urlInput, File outputFile, @Null monitor.updateProgress(overall, length); } conn.disconnect(); + } catch (IOException e) { + throw new IOException("Unable to download from " + urlInput, e); } - } public static T downloadStringCached(String url, String cacheName, ParseCallback parseCallback) throws IOException, ParseException{ diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java new file mode 100644 index 0000000000..5868948b46 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java @@ -0,0 +1,69 @@ +package net.kdt.pojavlaunch.utils; + +import android.util.Log; + +public class GameOptionsUtils { + /** + * Parse an integer. If the input value is null or not a valid integer, return the default value. + * @param value the String to parse + * @param defaultValue the default value + * @return the parsed value or default + */ + public static int parseIntDefault(String value, int defaultValue) { + if(value == null) return defaultValue; + try { + return Integer.parseInt(value); + }catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Decrease cloud rendering distance in order to avoid the Mali cloud rendering slowdown bug + */ + private static void fixDeathCloud() { + GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo(); + if(!info.isArm()) return; // Not an affected GPU + int cloudRange = parseIntDefault(MCOptionUtils.get("cloudRange"), 128); + if(cloudRange <= 64) return; // Not affected below 117 (but let's err on the safe side) + MCOptionUtils.set("cloudRange", "64"); + } + + /** + * Disable the Narrator. Clicking on the button, even though it says "Not Supported", turns it + * on and causes MC to generate insanely large log files when starting again + */ + private static void disableNarrator() { + if(parseIntDefault(MCOptionUtils.get("narrator"), 0) == 0) return; + MCOptionUtils.set("narrator", "0"); + } + + /** + * Disable fullscreen. The launcher runs always in fullscreen anyway, and this + * helps with some mods that can't tolerate an empty video mode list + */ + private static void disableFullscreen() { + String fullscreen = MCOptionUtils.get("fullscreen"); + if(fullscreen == null) return; + if(fullscreen.equals("true")) MCOptionUtils.set("fullscreen", "false"); + else if(fullscreen.equals("1")) MCOptionUtils.set("fullscreen","0"); + } + + public static void fixOptions(boolean isLtw) { + try { + MCOptionUtils.load(); + }catch (Exception e) { + Log.e("Tools", "Failed to load config", e); + } + + if(isLtw) fixDeathCloud(); + disableFullscreen(); + disableNarrator(); + + try { + MCOptionUtils.save(); + }catch (Exception e) { + Log.e("Tools", "Failed to save config", e); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java new file mode 100644 index 0000000000..a934238317 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java @@ -0,0 +1,67 @@ +package net.kdt.pojavlaunch.utils; + +import static android.os.Build.VERSION.SDK_INT; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class HashUtils { + @RequiresApi(26) + private static byte[] fileHashNio(MessageDigest messageDigest, Path p) throws IOException { + ByteBuffer buffer = ByteBuffer.allocateDirect(65535); + try(SeekableByteChannel channel = Files.newByteChannel(p, StandardOpenOption.READ)) { + while(true) { + buffer.rewind(); + if(channel.read(buffer) == -1) break; + buffer.flip(); + messageDigest.update(buffer); + } + } + return messageDigest.digest(); + } + + private static byte[] fileHashLegacy(MessageDigest messageDigest, File f) throws IOException { + byte[] sha1Buffer = new byte[65535]; + try (FileInputStream stream = new FileInputStream(f)){ + int readLen; + while((readLen = stream.read(sha1Buffer)) != -1) { + messageDigest.update(sha1Buffer, 0, readLen); + } + } + return messageDigest.digest(); + } + + public static byte[] fileHash(MessageDigest messageDigest, File f) throws IOException { + if (SDK_INT >= Build.VERSION_CODES.O) return fileHashNio(messageDigest, f.toPath()); + else return fileHashLegacy(messageDigest, f); + } + + public static boolean compareSHA1(File f, String sourceSHA) throws IOException{ + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + byte[] wantedBytes = Hex.decodeHex(sourceSHA.toCharArray()); + byte[] localFileBytes = fileHash(messageDigest, f); + return Arrays.equals(localFileBytes, wantedBytes); + }catch (NoSuchAlgorithmException e) { + throw new RuntimeException("WTF? SHA-1 digest missing!", e); + }catch (DecoderException e) { + throw new IOException("Bad SHA-1 hash: "+sourceSHA+" for file "+f.getName()); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java index 166ec8ac31..1b0eb01f66 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java @@ -1,6 +1,8 @@ package net.kdt.pojavlaunch.utils; import static net.kdt.pojavlaunch.Architecture.ARCH_X86; +import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid; +import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture; import static net.kdt.pojavlaunch.Architecture.is64BitsDevice; import static net.kdt.pojavlaunch.Tools.LOCAL_RENDERER; import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR; @@ -30,6 +32,7 @@ import net.kdt.pojavlaunch.multirt.Runtime; import net.kdt.pojavlaunch.plugins.FFmpegPlugin; import net.kdt.pojavlaunch.prefs.*; + import org.lwjgl.glfw.*; public class JREUtils { @@ -166,6 +169,8 @@ public static void relocateLibPath(Runtime runtime, String jreHome) { .append("/vendor/").append(libName).append(":") .append("/vendor/").append(libName).append("/hw:") .append(NATIVE_LIB_DIR); + // FIXME: Freetype is shipped inside lwjgl. We should ship it outside and use lwjgl native jars instead. + ldLibraryPath.append(String.format(":%s/lwjgl-3.3.3-natives/%s", Tools.DIR_DATA, archAsStringAndroid(getDeviceArchitecture()))); LD_LIBRARY_PATH = ldLibraryPath.toString(); } @@ -213,28 +218,32 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws } if(LOCAL_RENDERER != null) { - envMap.put("POJAV_RENDERER", LOCAL_RENDERER); + envMap.put("AMETHYST_RENDERER", LOCAL_RENDERER); if(LOCAL_RENDERER.equals("opengles3_ltw")) { envMap.put("LIBGL_ES", "3"); envMap.put("POJAVEXEC_EGL","libltw.so"); // Use ANGLE EGL } + if(LOCAL_RENDERER.equals("opengles_mobileglues")){ + envMap.put("MG_DIR_PATH", Tools.DIR_DATA + "/MobileGlues"); + envMap.put("POJAVEXEC_EGL","libmobileglues.so"); + } + if(LOCAL_RENDERER.equals("opengles2")){ + envMap.put("LIBGL_ES", "2"); // Krypton Wrapper crashes with 1 + } + if (LOCAL_RENDERER.equals("opengles3_desktopgl_zink_kopper")){ + envMap.put("POJAVEXEC_EGL","libEGL_mesa.so"); // Use Mesa EGL + if (Tools.shouldUseUBWC()) envMap.put("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1"); // Turnip fix for OneUI rendering issues + } + if (LOCAL_RENDERER.toLowerCase().contains("zink")){ + // This is sketch but it fixes a lot of things, if it causes problems we can just undo it. + envMap.put("MESA_GL_VERSION_OVERRIDE","4.6COMPAT"); + envMap.put("MESA_GLSL_VERSION_OVERRIDE","460"); + } } if(LauncherPreferences.PREF_BIG_CORE_AFFINITY) envMap.put("POJAV_BIG_CORE_AFFINITY", "1"); envMap.put("AWTSTUB_WIDTH", Integer.toString(CallbackBridge.windowWidth > 0 ? CallbackBridge.windowWidth : CallbackBridge.physicalWidth)); envMap.put("AWTSTUB_HEIGHT", Integer.toString(CallbackBridge.windowHeight > 0 ? CallbackBridge.windowHeight : CallbackBridge.physicalHeight)); - File customEnvFile = new File(Tools.DIR_GAME_HOME, "custom_env.txt"); - if (customEnvFile.exists() && customEnvFile.isFile()) { - BufferedReader reader = new BufferedReader(new FileReader(customEnvFile)); - String line; - while ((line = reader.readLine()) != null) { - // Not use split() as only split first one - int index = line.indexOf("="); - envMap.put(line.substring(0, index), line.substring(index + 1)); - } - reader.close(); - } - GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo(); if(!envMap.containsKey("LIBGL_ES") && LOCAL_RENDERER != null) { int glesMajor = info.glesMajorVersion; @@ -256,6 +265,8 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws envMap.put("POJAV_LOAD_TURNIP", "1"); } + readCustomEnv(envMap); // Must be last so it overrides anything the user sets for obvious reasons. + for (Map.Entry env : envMap.entrySet()) { Logger.appendToLog("Added custom env: " + env.getKey() + "=" + env.getValue()); try { @@ -274,6 +285,19 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws // return ldLibraryPath; } + private static void readCustomEnv(Map envMap) throws IOException { + File customEnvFile = new File(Tools.DIR_GAME_HOME, "custom_env.txt"); + if (customEnvFile.exists() && customEnvFile.isFile()) { + BufferedReader reader = new BufferedReader(new FileReader(customEnvFile)); + String line; + while ((line = reader.readLine()) != null) { + // Not use split() as only split first one + int index = line.indexOf("="); + envMap.put(line.substring(0, index), line.substring(index + 1)); + } + reader.close(); + } + } public static void launchJavaVM(final AppCompatActivity activity, final Runtime runtime, File gameDirectory, final List JVMArgs, final String userArgsString) throws Throwable { String runtimeHome = MultiRTUtils.getRuntimeHome(runtime.name).getAbsolutePath(); @@ -306,10 +330,17 @@ public static void launchJavaVM(final AppCompatActivity activity, final Runtime // Force LWJGL to use the Freetype library intended for it, instead of using the one // that we ship with Java (since it may be older than what's needed) - userArgs.add("-Dorg.lwjgl.freetype.libname="+ NATIVE_LIB_DIR+"/libfreetype.so"); + userArgs.add("-Dorg.lwjgl.freetype.libname="+ Tools.lwjglNativesDir +"/libfreetype.so"); + // Our spirv-cross is compiled shared, so it gets named shared. + userArgs.add("-Dorg.lwjgl.spvc.libname=spirv-cross-c-shared"); + + // We don't have jemalloc for our LWJGL so set the allocator to system to avoid error logs + userArgs.add("-Dorg.lwjgl.system.allocator=system"); // Some phones are not using the right number of cores, fix that userArgs.add("-XX:ActiveProcessorCount=" + java.lang.Runtime.getRuntime().availableProcessors()); + // Adds/changes methods for compatibility + userArgs.add("-javaagent:"+new File(Tools.DIR_DATA,"methods_injector_agent/methods_injector_agent.jar").getAbsolutePath()); userArgs.addAll(JVMArgs); activity.runOnUiThread(() -> Toast.makeText(activity, activity.getString(R.string.autoram_info_msg,LauncherPreferences.PREF_RAM_ALLOCATION), Toast.LENGTH_SHORT).show()); @@ -464,20 +495,23 @@ public static String loadGraphicsLibrary(){ case "opengles2": case "opengles2_5": case "opengles3": - renderLibrary = "libgl4es_114.so"; break; + renderLibrary = "libgl4es_115.so"; break; case "vulkan_zink": renderLibrary = "libOSMesa.so"; break; + case "opengles_mobileglues": renderLibrary = "libmobileglues.so"; break; + case "opengles3_desktopgl_zink": renderLibrary = "libglxshim.so"; break; case "opengles3_ltw" : renderLibrary = "libltw.so"; break; + case "opengles3_KW" : renderLibrary = "libng_gl4es.so"; break; default: Log.w("RENDER_LIBRARY", "No renderer selected, defaulting to opengles2"); - renderLibrary = "libgl4es_114.so"; + renderLibrary = "libgl4es_115.so"; break; } if (!dlopen(renderLibrary) && !dlopen(findInLdLibPath(renderLibrary))) { Log.e("RENDER_LIBRARY","Failed to load renderer " + renderLibrary + ". Falling back to GL4ES 1.1.4"); LOCAL_RENDERER = "opengles2"; - renderLibrary = "libgl4es_114.so"; - dlopen(NATIVE_LIB_DIR + "/libgl4es_114.so"); + renderLibrary = "libgl4es_115.so"; + dlopen(NATIVE_LIB_DIR + "/libgl4es_115.so"); } return renderLibrary; } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java new file mode 100644 index 0000000000..4e6a35a9b1 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java @@ -0,0 +1,34 @@ +package net.kdt.pojavlaunch.utils; + +public class MavenNameUtils { + + public static String mavenBaseName(String libName) { + String[] libInfos = libName.split(":"); + StringBuilder builder = new StringBuilder() + .append(libInfos[0]).append(':').append(libInfos[1]); + for(int i = 3; i < libInfos.length; i++) { + builder.append(':').append(libInfos[i]); + } + return builder.toString(); + } + + public static StringBuilder mavenNameToPathBuilder(String libName) { + String[] libInfos = libName.split(":"); + return new StringBuilder() + .append(libInfos[0].replaceAll("\\.", "/")) + .append('/') + .append(libInfos[1]) + .append('/') + .append(libInfos[2]) + .append('/') + .append(libInfos[1]).append('-').append(libInfos[2]); + } + + public static String mavenNameToAarPath(String libName) { + return mavenNameToPathBuilder(libName).append(".aar").toString(); + } + + public static String mavenNameToPath(String libName) { + return mavenNameToPathBuilder(libName).append(".jar").toString(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java new file mode 100644 index 0000000000..6a7d3cfb94 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java @@ -0,0 +1,80 @@ +package net.kdt.pojavlaunch.utils; + +import static android.os.Build.VERSION.SDK_INT; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.os.Build; + +import net.kdt.pojavlaunch.Architecture; +import net.kdt.pojavlaunch.Tools; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import git.artdeell.mojo.R; + +public class RendererCompatUtil { + private static RenderersList sCompatibleRenderers; + + public static boolean checkVulkanSupport(PackageManager packageManager) { + if(SDK_INT >= Build.VERSION_CODES.N) { + return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL) && + packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION); + } + return false; + } + + /** Return the renderers that are compatible with this device */ + public static RenderersList getCompatibleRenderers(Context context) { + if(sCompatibleRenderers != null) return sCompatibleRenderers; + Resources resources = context.getResources(); + String[] defaultRenderers = resources.getStringArray(R.array.renderer_values); + String[] defaultRendererNames = resources.getStringArray(R.array.renderer); + boolean deviceHasVulkan = checkVulkanSupport(context.getPackageManager()); + // Current Mesa requires API29+ + boolean deviceCompatibleMesa = SDK_INT >= 29; + boolean deviceHasOpenGLES3 = JREUtils.getDetectedVersion() >= 3; + // LTW is an optional dependency + boolean appHasLtw = new File(Tools.NATIVE_LIB_DIR, "libltw.so").exists(); + List rendererIds = new ArrayList<>(defaultRenderers.length); + List rendererNames = new ArrayList<>(defaultRendererNames.length); + for(int i = 0; i < defaultRenderers.length; i++) { + String rendererId = defaultRenderers[i]; + if(rendererId.contains("vulkan") && !deviceHasVulkan) continue; + if(rendererId.contains("zink") && !deviceCompatibleMesa) continue; + // freedreno is available only on Adreno GPUs + if(rendererId.contains("freedreno") && (!(GLInfoUtils.getGlInfo().isAdreno()) || !deviceCompatibleMesa)) continue; + if(rendererId.contains("ltw") && (!deviceHasOpenGLES3 || !appHasLtw)) continue; + rendererIds.add(rendererId); + rendererNames.add(defaultRendererNames[i]); + } + sCompatibleRenderers = new RenderersList(rendererIds, + rendererNames.toArray(new String[0])); + + return sCompatibleRenderers; + } + + /** Checks if the renderer Id is compatible with the current device */ + public static boolean checkRendererCompatible(Context context, String rendererName) { + return getCompatibleRenderers(context).rendererIds.contains(rendererName); + } + + /** Releases the cache of compatible renderers. */ + public static void releaseRenderersCache() { + sCompatibleRenderers = null; + System.gc(); + } + + public static class RenderersList { + public final List rendererIds; + public final String[] rendererDisplayNames; + + public RenderersList(List rendererIds, String[] rendererDisplayNames) { + this.rendererIds = rendererIds; + this.rendererDisplayNames = rendererDisplayNames; + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java new file mode 100644 index 0000000000..97cbd80361 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java @@ -0,0 +1,97 @@ +package net.kdt.pojavlaunch.utils; + +import android.content.res.AssetManager; +import android.util.ArrayMap; +import android.util.Base64; + +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.Map; + +public class SignatureCheckUtil { + private final PublicKey mPublicKey; + + public SignatureCheckUtil(PublicKey mPublicKey) { + this.mPublicKey = mPublicKey; + } + + /** + * Decode a bundle of signatures. A bundle of signatures has the following format: + * fileName1:base64-rsa4096-signature + * fileName2:base64-rsa4096-signature + * Invalid signatures aren't included in the resulting Map. + * @param bundle the original string of the bundle + * @return each decoded signature mapped to each file name + */ + public static Map decodeSignatureBundle(String bundle) { + String[] signatureLines = bundle.split("\n"); + ArrayMap signatures = new ArrayMap<>(signatureLines.length); + for(String signatureLine : signatureLines) { + String[] splitSignLine = signatureLine.split(":"); + if(splitSignLine.length != 2) continue; + try { + byte[] signatureBytes = decodeRsa4096FromBase64(splitSignLine[1]); + if(signatureBytes == null) continue; + signatures.put(splitSignLine[0], signatureBytes); + }catch (IllegalArgumentException ignored) {} + } + return signatures; + } + + /** + * Decode an RSA4096-encrypted signature from a Base64 string + * @param base64 the original base64 data + * @return the decoded bytes, or null if the data length isn't correct + */ + public static byte[] decodeRsa4096FromBase64(String base64) { + byte[] rsaBytes = Base64.decode(base64, Base64.DEFAULT); + if(rsaBytes.length != 512) return null; + return rsaBytes; + } + + /** + * Verifies the signature of an input stream against the cert.pem certificate from app assets + * @param inputStream the original file stream + * @param signatureBytes the bytes of the encrypted signature + * @return whether the file signature check passed or not + * @throws IOException if there was an error while reading the file + */ + public boolean verify(InputStream inputStream, byte[] signatureBytes) throws IOException { + byte[] ingestionBuffer = new byte[65535]; + try { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(mPublicKey); + for (int i = 0; i != -1; i = inputStream.read(ingestionBuffer)) { + signature.update(ingestionBuffer, 0, i); + } + return signature.verify(signatureBytes); + }catch (InvalidKeyException | NoSuchAlgorithmException | SignatureException e) { + throw new RuntimeException(e); + } + } + + /** + * Reads in the cert.pem certificate from application assets and creates a SignatureCheckUtil + * to verify data against this certificate + * @param assetManager the AssetManager used to read the cert.pem file + * @return the SignatureCheckUtil instance + * @throws IOException if reading fails + */ + public static SignatureCheckUtil create(AssetManager assetManager) throws IOException { + try (InputStream certificateStream = assetManager.open("cert.pem")) { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Certificate certificate = certificateFactory.generateCertificate(certificateStream); + return new SignatureCheckUtil(certificate.getPublicKey()); + }catch (CertificateException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java new file mode 100644 index 0000000000..e5bbb7bd6b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java @@ -0,0 +1,114 @@ +package net.kdt.pojavlaunch.utils; + +import android.content.Context; +import android.os.Vibrator; + +import top.fifthlight.touchcontroller.proxy.client.LauncherProxyClient; +import top.fifthlight.touchcontroller.proxy.client.MessageTransport; +import top.fifthlight.touchcontroller.proxy.client.android.transport.UnixSocketTransportKt; +import top.fifthlight.touchcontroller.proxy.message.VibrateMessage; + +import android.system.ErrnoException; +import android.system.Os; +import android.util.Log; +import android.util.SparseIntArray; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import net.kdt.pojavlaunch.prefs.LauncherPreferences; + +public class TouchControllerUtils { + private TouchControllerUtils() { + } + + public static LauncherProxyClient proxyClient; + private static final String socketName = "Copper"; + + private static class VibrationHandler implements LauncherProxyClient.VibrationHandler { + private final Vibrator vibrator; + + public VibrationHandler(Vibrator vibrator) { + this.vibrator = vibrator; + } + + @Override + @SuppressWarnings("DEPRECATION") + public void vibrate(@NonNull VibrateMessage.Kind kind) { + vibrator.vibrate(LauncherPreferences.PREF_TOUCHCONTROLLER_VIBRATE_LENGTH); + } + } + + private static final SparseIntArray pointerIdMap = new SparseIntArray(); + private static int nextPointerId = 1; + + public static void processTouchEvent(MotionEvent motionEvent, View view) { + if (proxyClient == null) { + return; + } + int pointerId; + switch (motionEvent.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + pointerId = nextPointerId++; + pointerIdMap.put(motionEvent.getPointerId(0), pointerId); + proxyClient.addPointer(pointerId, motionEvent.getX(0) / view.getWidth(), motionEvent.getY(0) / view.getHeight()); + break; + case MotionEvent.ACTION_POINTER_DOWN: + pointerId = nextPointerId++; + int actionIndex = motionEvent.getActionIndex(); + pointerIdMap.put(motionEvent.getPointerId(actionIndex), pointerId); + proxyClient.addPointer(pointerId, motionEvent.getX(actionIndex) / view.getWidth(), motionEvent.getY(actionIndex) / view.getHeight()); + break; + case MotionEvent.ACTION_MOVE: + for (int i = 0; i < motionEvent.getPointerCount(); i++) { + pointerId = pointerIdMap.get(motionEvent.getPointerId(i)); + if (pointerId == 0) { + Log.d("TouchController", "Move pointerId is 0"); + continue; + } + proxyClient.addPointer(pointerId, motionEvent.getX(i) / view.getWidth(), motionEvent.getY(i) / view.getHeight()); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (proxyClient != null) { + proxyClient.clearPointer(); + pointerIdMap.clear(); + } + break; + case MotionEvent.ACTION_POINTER_UP: + if (proxyClient != null) { + int i = motionEvent.getActionIndex(); + pointerId = pointerIdMap.get(motionEvent.getPointerId(i)); + if (pointerId == 0) { + Log.d("TouchController", "Pointer up pointerId is 0"); + break; + } + pointerIdMap.delete(pointerId); + proxyClient.removePointer(pointerId); + } + break; + } + } + + public static void initialize(Context context) { + if (proxyClient != null) { + return; + } + try { + Os.setenv("TOUCH_CONTROLLER_PROXY_SOCKET", socketName, true); + } catch (ErrnoException e) { + Log.w("TouchController", "Failed to set TouchController environment variable", e); + } + MessageTransport transport = UnixSocketTransportKt.UnixSocketTransport(socketName); + proxyClient = new LauncherProxyClient(transport); + proxyClient.run(); + Vibrator vibrator = ContextCompat.getSystemService(context, Vibrator.class); + if (vibrator != null) { + LauncherProxyClient.VibrationHandler vibrationHandler = new VibrationHandler(vibrator); + proxyClient.setVibrationHandler(vibrationHandler); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java new file mode 100644 index 0000000000..86d6c8a056 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java @@ -0,0 +1,419 @@ +package net.kdt.pojavlaunch.utils.jre; + +import android.util.ArrayMap; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import net.kdt.pojavlaunch.Architecture; +import net.kdt.pojavlaunch.JMinecraftVersionList; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; +import net.kdt.pojavlaunch.instances.Instance; +import net.kdt.pojavlaunch.lifecycle.LifecycleAwareAlertDialog; +import net.kdt.pojavlaunch.multirt.MultiRTUtils; +import net.kdt.pojavlaunch.multirt.Runtime; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.DateUtils; +import net.kdt.pojavlaunch.utils.FileUtils; +import net.kdt.pojavlaunch.utils.GLInfoUtils; +import net.kdt.pojavlaunch.utils.GameOptionsUtils; +import net.kdt.pojavlaunch.utils.JREUtils; +import net.kdt.pojavlaunch.utils.JSONUtils; +import net.kdt.pojavlaunch.utils.MCOptionUtils; +import net.kdt.pojavlaunch.utils.OldVersionsUtils; +import net.kdt.pojavlaunch.utils.RendererCompatUtil; + +import java.io.File; +import java.io.IOException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import git.artdeell.mojo.R; + +public class GameRunner { + /** + * Optimization mods based on Sodium can mitigate the render distance issue. Check if Sodium + * or its derivative is currently installed to skip the render distance check. + * @param gameDir current game directory + * @return whether sodium or a sodium-based mod is installed + */ + private static boolean hasSodium(File gameDir) { + File modsDir = new File(gameDir, "mods"); + File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); + if(mods == null) return false; + for(File file : mods) { + String name = file.getName(); + if(name.contains("sodium") || + name.contains("embeddium") || + name.contains("rubidium")) return true; + } + return false; + } + + /** + * Check if Angelica is currently installed to allow usage of LTW + * @param gameDir current game directory + * @return whether Angelica is installed + */ + private static boolean hasAngelica(File gameDir) { + File modsDir = new File(gameDir, "mods"); + File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar")); + if(mods == null) return false; + for(File file : mods) { + String name = file.getName(); + if(name.contains("angelica")) return true; + } + return false; + } + + /** + * Initialize OpenGL and do checks to see if the GPU of the device is affected by the render + * distance issue. + + * Currently only checks whether the user has an Adreno GPU capable of OpenGL ES 3. + + * This issue is caused by a very severe limit on the amount of GL buffer names that could be allocated + * by the Adreno properietary GLES driver. + + * @return whether the GPU is affected by the Large Thin Wrapper render distance issue on vanilla + */ + + private static boolean affectedByRenderDistanceIssue(JMinecraftVersionList.Version version) throws ParseException { + if(LauncherPreferences.PREF_USE_ANGLE) return false; + GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo(); + return info.isAdreno() && + info.glesMajorVersion >= 3 && + // 1.21.5 fixes the RD issue, released on march 25 2025 + DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2025, 2, 25); + } + + private static boolean checkRenderDistance(JMinecraftVersionList.Version version, File gamedir) throws ParseException { + if(!affectedByRenderDistanceIssue(version)) return false; + if(hasSodium(gamedir)) return false; + try { + MCOptionUtils.load(); + }catch (Exception e) { + Log.e("Tools", "Failed to load config", e); + } + int renderDistance = GameOptionsUtils.parseIntDefault(MCOptionUtils.get("renderDistance"),12); + // 7 is the render distance "magic number" above which MC creates too many buffers + // for Adreno's OpenGL ES implementation + return renderDistance > 7; + } + + private static boolean isGl4esCompatible(JMinecraftVersionList.Version version) throws Exception{ + return DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2025, 1, 7); + } + + private static boolean isCompatContext(JMinecraftVersionList.Version version) throws Exception{ + // Day before the release date of 21w10a, the first OpenGL 3 Core Minecraft version + return DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2021, 3, 9); + } + + private static boolean showDialog(AppCompatActivity activity, int message) throws InterruptedException { + LifecycleAwareAlertDialog.DialogCreator dialogCreator = ((alertDialog, dialogBuilder) -> + dialogBuilder.setMessage(activity.getString(message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (d, w)->{})); + return LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator); + } + + // Autoswitch to LTW if supported, otherwise - crash with resId dialog message. Returns LTW renderer strings if succeeded + private static String switchLtw(boolean hasLtw, Instance instance, AppCompatActivity activity, int resId) throws InterruptedException, IOException { + if(hasLtw) { + String ltwRenderer = "opengles3_ltw"; + instance.renderer = ltwRenderer; + instance.write(); + return ltwRenderer; + }else { + showDialog(activity, resId); + System.exit(0); + return null; + } + } + + public static void launchMinecraft(final AppCompatActivity activity, MinecraftAccount minecraftAccount, + Instance instance, String versionId, File[] classpath, String rendererName) throws Throwable { + int freeDeviceMemory = Tools.getFreeDeviceMemory(activity); + int localeString; + int freeAddressSpace = Architecture.is32BitsDevice() ? Tools.getMaxContinuousAddressSpaceSize() : -1; + Log.i("MemStat", "Free RAM: " + freeDeviceMemory + " Addressable: " + freeAddressSpace); + if(freeDeviceMemory > freeAddressSpace && freeAddressSpace != -1) { + freeDeviceMemory = freeAddressSpace; + localeString = R.string.address_memory_warning_msg; + } else { + localeString = R.string.memory_warning_msg; + } + + if(LauncherPreferences.PREF_RAM_ALLOCATION > freeDeviceMemory) { + int finalDeviceMemory = freeDeviceMemory; + LifecycleAwareAlertDialog.DialogCreator dialogCreator = (dialog, builder) -> + builder.setMessage(activity.getString(localeString, finalDeviceMemory, LauncherPreferences.PREF_RAM_ALLOCATION)) + .setPositiveButton(android.R.string.ok, (d, w)->{}); + + if(LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator)) { + return; // If the dialog's lifecycle has ended, return without + // actually launching the game, thus giving us the opportunity + // to start after the activity is shown again + } + } + File gamedir = instance.getGameDirectory(); + JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionId); + + // Switch renderer to GL4ES when running a compat context version on LTW + if(isCompatContext(versionInfo) && !hasAngelica(gamedir) && rendererName.equals("opengles3_ltw")) { + instance.renderer = rendererName = "opengles2"; + instance.write(); + } + + boolean isGl4es = rendererName.equals("opengles2"); + boolean ltwSupported = RendererCompatUtil.getCompatibleRenderers(activity).rendererIds.contains("opengles3_ltw"); + // Block Sodium from running with GL4ES on 1.17+ + if(!isCompatContext(versionInfo) && isGl4es && hasSodium(gamedir)) { + rendererName = switchLtw(ltwSupported, instance, activity, R.string.compat_sodium_not_supported); + } + + // Switch renderer to LTW when running 1.21.5 + if(!isGl4esCompatible(versionInfo) && isGl4es) { + rendererName = switchLtw(ltwSupported, instance, activity, R.string.compat_version_not_supported); + } + RendererCompatUtil.releaseRenderersCache(); + + boolean isLtw = rendererName.equals("opengles3_ltw"); + + if(isLtw && checkRenderDistance(versionInfo, gamedir)) { + if(showDialog(activity, R.string.ltw_render_distance_warning_msg)) return; + // If the code goes here, it means that the user clicked "OK". Fix the render distance. + try { + MCOptionUtils.set("renderDistance", "7"); + MCOptionUtils.save(); + }catch (Exception e) { + Log.e("Tools", "Failed to fix render distance setting", e); + } + } + + GameOptionsUtils.fixOptions(isLtw); + + if(isLtw && GLInfoUtils.getGlInfo().forcedMsaa) { + if(showDialog(activity, R.string.ltw_4x_msaa_warning_msg)) return; + } + + int requiredJavaVersion = 8; + if(versionInfo.javaVersion != null) requiredJavaVersion = versionInfo.javaVersion.majorVersion; + + Runtime runtime = MultiRTUtils.forceReread(pickRuntime(instance, requiredJavaVersion)); + + // Pre-process specific files + disableSplash(gamedir); + List launchArgs = getMinecraftClientArgs(minecraftAccount, versionInfo, gamedir); + + // Select the appropriate openGL version + OldVersionsUtils.selectOpenGlVersion(versionInfo); + + ArrayList launchClassPath = new ArrayList<>(classpath.length); + for(File classpathEntry : classpath) { + String entryPath = classpathEntry.getAbsolutePath(); + if(!classpathEntry.exists()) { + Log.w("GameRunner", "Skipped classpath entry " + entryPath + " because it is missing"); + } + launchClassPath.add(entryPath); + } + launchClassPath.trimToSize(); + + List javaArgList = new ArrayList<>(); + + if (versionInfo.logging != null && versionInfo.logging.client != null && versionInfo.logging.client.file != null) { + String configFile = Tools.DIR_DATA + "/security/" + versionInfo.logging.client.file.id.replace("client", "log4j-rce-patch"); + if (!new File(configFile).exists()) { + configFile = Tools.DIR_GAME_NEW + "/" + versionInfo.logging.client.file.id; + } + javaArgList.add("-Dlog4j.configurationFile=" + configFile); + } + + File versionSpecificNativesDir = new File(Tools.DIR_CACHE, "natives/"+versionId); + if(versionSpecificNativesDir.exists()) { + String dirPath = versionSpecificNativesDir.getAbsolutePath(); + javaArgList.add("-Djava.library.path="+dirPath+":"+Tools.NATIVE_LIB_DIR); + javaArgList.add("-Djna.boot.library.path="+dirPath); + } + + File lwjglExtractDir = new File(Tools.DIR_CACHE, "lwjgl_native/"+versionId); + FileUtils.ensureDirectory(lwjglExtractDir); + javaArgList.add("-Dorg.lwjgl.system.SharedLibraryExtractPath="+lwjglExtractDir.getAbsolutePath()); + + addAuthlibInjectorArgs(javaArgList, minecraftAccount); + + javaArgList.addAll(getMinecraftJVMArgs(versionId)); + + javaArgList.addAll(JREUtils.parseJavaArguments(instance.getLaunchArgs())); + + JREUtils.setEnviroimentForGame(activity, rendererName); + JREUtils.chdir(instance.getGameDirectory().getAbsolutePath()); + + String rendererLibrary = JREUtils.loadGraphicsLibrary(rendererName); + if(rendererLibrary == null) { + Log.i("GameRunner", "Falling back to GL4ES 1.1.4"); + rendererName = "opengles2"; + rendererLibrary = JREUtils.loadGraphicsLibrary(rendererName); + } + if(rendererLibrary == null) { + if(showDialog(activity, R.string.gr_err_renderer_load_Failed)) return; + System.exit(0); + } + javaArgList.add("-Dorg.lwjgl.opengl.libname=libGLMojo.so"); + javaArgList.add("-Dorg.lwjgl.freetype.libname="+ Tools.NATIVE_LIB_DIR+"/libfreetype.so"); + + activity.runOnUiThread(() -> Toast.makeText(activity, activity.getString(R.string.autoram_info_msg,LauncherPreferences.PREF_RAM_ALLOCATION), Toast.LENGTH_SHORT).show()); + + Log.i("GameRunner", "Running with "+ launchArgs.toString()); + + try { + JavaRunner.nativeSetupExit(activity); + JavaRunner.startJvm(runtime, javaArgList, launchClassPath, versionInfo.mainClass, launchArgs); + }catch (VMLoadException e) { + LifecycleAwareAlertDialog.DialogCreator dialogCreator = (dialog, builder) -> + builder.setMessage(e.toString(activity)).setPositiveButton(android.R.string.ok, (d, w)->{}); + + if(LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator)) { + return; + } + } + + Tools.fullyExit(); + } + + private static void disableSplash(File dir) { + File configDir = new File(dir, "config"); + if(FileUtils.ensureDirectorySilently(configDir)) { + File forgeSplashFile = new File(dir, "config/splash.properties"); + String forgeSplashContent = "enabled=true"; + try { + if (forgeSplashFile.exists()) { + forgeSplashContent = Tools.read(forgeSplashFile.getAbsolutePath()); + } + if (forgeSplashContent.contains("enabled=true")) { + Tools.write(forgeSplashFile, + forgeSplashContent.replace("enabled=true", "enabled=false")); + } + } catch (IOException e) { + Log.w(Tools.APP_NAME, "Could not disable Forge 1.12.2 and below splash screen!", e); + } + } else { + Log.w(Tools.APP_NAME, "Failed to create the configuration directory"); + } + } + + private static void addAuthlibInjectorArgs(List javaArgList, MinecraftAccount minecraftAccount) { + String injectorUrl = minecraftAccount.authType.injectorUrl; + if(injectorUrl == null) return; + javaArgList.add("-javaagent:"+Tools.DIR_DATA+"/authlib-injector/authlib-injector.jar="+injectorUrl); + } + + private static List getMinecraftJVMArgs(String versionName) { + JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionName, true); + // Parse Forge 1.17+ additional JVM Arguments + if (versionInfo.inheritsFrom == null || versionInfo.arguments == null || versionInfo.arguments.jvm == null) { + return Collections.emptyList(); + } + + Map varArgMap = new ArrayMap<>(); + varArgMap.put("classpath_separator", ":"); + varArgMap.put("library_directory", Tools.DIR_HOME_LIBRARY); + varArgMap.put("version_name", versionInfo.id); + varArgMap.put("natives_directory", Tools.NATIVE_LIB_DIR); + + List minecraftArgs = new ArrayList<>(); + if (versionInfo.arguments != null) { + for (Object arg : versionInfo.arguments.jvm) { + if (arg instanceof String) { + minecraftArgs.add((String) arg); + } //TODO: implement (?maybe?) + } + } + return JSONUtils.insertJSONValueList(minecraftArgs, varArgMap); + } + + private static List getMinecraftClientArgs(MinecraftAccount profile, JMinecraftVersionList.Version versionInfo, File gameDir) { + String username = profile.username; + String versionName = versionInfo.id; + if (versionInfo.inheritsFrom != null) { + versionName = versionInfo.inheritsFrom; + } + + String userType = "mojang"; + try { + Date creationDate = DateUtils.getOriginalReleaseDate(versionInfo); + // Minecraft 22w43a which adds chat reporting (and signing) was released on + // 26th October 2022. So, if the date is not before that (meaning it is equal or higher) + // change the userType to MSA to fix the missing signature + if(creationDate != null && !DateUtils.dateBefore(creationDate, 2022, 9, 26)) { + userType = "msa"; + } + }catch (ParseException e) { + Log.e("CheckForProfileKey", "Failed to determine profile creation date, using \"mojang\"", e); + } + + + Map varArgMap = new ArrayMap<>(); + varArgMap.put("auth_session", profile.accessToken); // For legacy versions of MC + varArgMap.put("auth_access_token", profile.accessToken); + varArgMap.put("auth_player_name", username); + varArgMap.put("auth_uuid", profile.profileId.replace("-", "")); + varArgMap.put("auth_xuid", profile.xuid); + varArgMap.put("assets_root", Tools.ASSETS_PATH); + varArgMap.put("assets_index_name", versionInfo.assets); + varArgMap.put("game_assets", Tools.ASSETS_PATH); + varArgMap.put("game_directory", gameDir.getAbsolutePath()); + varArgMap.put("user_properties", "{}"); + varArgMap.put("user_type", userType); + varArgMap.put("version_name", versionName); + varArgMap.put("version_type", versionInfo.type); + + List minecraftArgs = new ArrayList<>(); + if (versionInfo.arguments != null && versionInfo.arguments.game != null) { + // Support Minecraft 1.13+ + for (Object arg : versionInfo.arguments.game) { + if (arg instanceof String) { + minecraftArgs.add((String) arg); + } //TODO: implement else clause + } + } + if(versionInfo.minecraftArguments != null){ + minecraftArgs.addAll(splitAndFilterEmpty(versionInfo.minecraftArguments)); + } + return JSONUtils.insertJSONValueList(minecraftArgs, varArgMap); + } + + private static List splitAndFilterEmpty(String argStr) { + List strList = new ArrayList<>(); + for (String arg : argStr.split(" ")) { + if (!arg.isEmpty()) { + strList.add(arg); + } + } + return strList; + } + + public static @NonNull String pickRuntime(Instance instance, int targetJavaVersion) { + String runtime = Tools.getSelectedRuntime(instance); + String profileRuntime = instance.selectedRuntime; + Runtime pickedRuntime = MultiRTUtils.read(runtime); + if(runtime == null || pickedRuntime.javaVersion == 0 || pickedRuntime.javaVersion < targetJavaVersion) { + String preferredRuntime = MultiRTUtils.getNearestJreName(targetJavaVersion); + if(preferredRuntime == null) throw new RuntimeException("Failed to autopick runtime!"); + if(profileRuntime != null) { + instance.selectedRuntime = preferredRuntime; + instance.maybeWrite(); + } + runtime = preferredRuntime; + } + return runtime; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java new file mode 100644 index 0000000000..4ff6c0c39b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java @@ -0,0 +1,292 @@ +package net.kdt.pojavlaunch.utils.jre; + +import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR; + +import android.content.Context; +import android.os.Build; +import android.system.ErrnoException; +import android.system.Os; +import android.util.Log; + +import androidx.annotation.NonNull; + +import net.kdt.pojavlaunch.AWTCanvasView; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.multirt.MultiRTUtils; +import net.kdt.pojavlaunch.multirt.Runtime; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.utils.JREUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.ListIterator; +import java.util.Objects; +import java.util.TimeZone; + +public class JavaRunner { + + private static boolean getCacioJavaArgs(List javaArgList, boolean isJava8) { + // Caciocavallo config AWT-enabled version + javaArgList.add("-Djava.awt.headless=false"); + javaArgList.add("-Dcacio.managed.screensize=" + AWTCanvasView.AWT_CANVAS_WIDTH + "x" + AWTCanvasView.AWT_CANVAS_HEIGHT); + javaArgList.add("-Dcacio.font.fontmanager=sun.awt.X11FontManager"); + javaArgList.add("-Dcacio.font.fontscaler=sun.font.FreetypeFontScaler"); + javaArgList.add("-Dswing.defaultlaf=javax.swing.plaf.metal.MetalLookAndFeel"); + if (isJava8) { + javaArgList.add("-Dawt.toolkit=net.java.openjdk.cacio.ctc.CTCToolkit"); + javaArgList.add("-Djava.awt.graphicsenv=net.java.openjdk.cacio.ctc.CTCGraphicsEnvironment"); + StringBuilder cacioClasspath = createCacioClasspath(); + javaArgList.add(cacioClasspath.toString()); + return false; + } else { + File caciocavallo17AgentDir = new File(Tools.DIR_GAME_HOME, "caciocavallo17"); + File[] cacioJars = caciocavallo17AgentDir.listFiles((file, s) ->s.endsWith(".jar")); + if(cacioJars == null || cacioJars.length < 1) { + return false; + } + javaArgList.add("-javaagent:"+cacioJars[0].getAbsolutePath()); + javaArgList.add("-Dawt.toolkit=com.github.caciocavallosilano.cacio.ctc.CTCToolkit"); + javaArgList.add("-Djava.awt.graphicsenv=com.github.caciocavallosilano.cacio.ctc.CTCGraphicsEnvironment"); + + javaArgList.add("--add-exports=java.desktop/java.awt=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/java.awt.peer=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.awt.image=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.java2d=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/java.awt.dnd.peer=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.awt=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.awt.event=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.awt.datatransfer=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.desktop/sun.font=ALL-UNNAMED"); + javaArgList.add("--add-exports=java.base/sun.security.action=ALL-UNNAMED"); + javaArgList.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + javaArgList.add("--add-opens=java.desktop/java.awt=ALL-UNNAMED"); + javaArgList.add("--add-opens=java.desktop/sun.font=ALL-UNNAMED"); + javaArgList.add("--add-opens=java.desktop/sun.java2d=ALL-UNNAMED"); + javaArgList.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + return true; + } + } + + @NonNull + private static StringBuilder createCacioClasspath() { + StringBuilder cacioClasspath = new StringBuilder(); + cacioClasspath.append("-Xbootclasspath/p"); + File cacioDir = new File(Tools.DIR_GAME_HOME, "caciocavallo"); + File[] cacioFiles = cacioDir.listFiles(); + if (cacioFiles != null) { + for (File file : cacioFiles) { + if (file.getName().endsWith(".jar")) { + cacioClasspath.append(":").append(file.getAbsolutePath()); + } + } + } + return cacioClasspath; + } + + /** + * Gives an argument list filled with both the user args + * and the auto-generated ones (eg. the window resolution). + * @return A list filled with args. + */ + private static List getJavaArgs(String runtimeHome, List userArguments) { + String resolvFile; + resolvFile = new File(Tools.DIR_DATA,"resolv.conf").getAbsolutePath(); + + userArguments.add(0, "-Xms"+LauncherPreferences.PREF_RAM_ALLOCATION+"M"); + userArguments.add(0, "-Xmx"+LauncherPreferences.PREF_RAM_ALLOCATION+"M"); + + ArrayList overridableArguments = new ArrayList<>(Arrays.asList( + "-Djava.home=" + runtimeHome, + "-Djava.io.tmpdir=" + Tools.DIR_CACHE.getAbsolutePath(), + "-Djna.boot.library.path=" + NATIVE_LIB_DIR, + "-Duser.home=" + Tools.DIR_GAME_HOME, + "-Duser.language=" + System.getProperty("user.language"), + "-Dos.name=Linux", + "-Dos.version=Android-" + Build.VERSION.RELEASE, + "-Dpojav.path.minecraft=" + Tools.DIR_GAME_NEW, + "-Dpojav.path.private.account=" + Tools.DIR_ACCOUNT_NEW, + "-Duser.timezone=" + TimeZone.getDefault().getID(), + + "-Dorg.lwjgl.vulkan.libname=libvulkan.so", + "-Dorg.lwjgl.spvc.libname=spirv-cross-c-shared", + "-Dorg.lwjgl.system.allocator=system", + //LWJGL 3 DEBUG FLAGS + //"-Dorg.lwjgl.util.Debug=true", + //"-Dorg.lwjgl.util.DebugFunctions=true", + //"-Dorg.lwjgl.util.DebugLoader=true", + // GLFW Stub width height + "-Dglfwstub.initEgl=false", + "-Dext.net.resolvPath=" +resolvFile, + "-Dlog4j2.formatMsgNoLookups=true", //Log4j RCE mitigation + "-Dfml.earlyprogresswindow=false", //Forge 1.14+ workaround + "-Dloader.disable_forked_guis=true", + "-Dsodium.checks.issue2561=false", + "-Djdk.lang.Process.launchMechanism=FORK" // Default is POSIX_SPAWN which requires starting jspawnhelper, which doesn't work on Android + )); + List additionalArguments = new ArrayList<>(); + for(String arg : overridableArguments) { + String strippedArg = arg.substring(0,arg.indexOf('=')); + boolean add = true; + for(String uarg : userArguments) { + if(uarg.startsWith(strippedArg)) { + add = false; + break; + } + } + if(add) + additionalArguments.add(arg); + else + Log.i("ArgProcessor","Arg skipped: "+arg); + } + + //Add all the arguments + userArguments.addAll(additionalArguments); + return userArguments; + } + + private static File getVmPath(File runtimeHomeDir, String arch, String flavor) { + if(arch != null) return new File(runtimeHomeDir, "lib/"+arch+"/"+flavor+"/libjvm.so"); + else return new File(runtimeHomeDir, "lib/"+flavor+"/libjvm.so"); + } + + private static File findVmForArch(File runtimeHomeDir, String arch) { + File finalPath; + if((finalPath = getVmPath(runtimeHomeDir, arch, "server")).exists()) return finalPath; + if((finalPath = getVmPath(runtimeHomeDir, arch, "client")).exists()) return finalPath; + return null; + } + + private static File findVmPath(File runtimeHomeDir, String runtimeArch) { + File finalPath; + if((finalPath = findVmForArch(runtimeHomeDir, null)) != null) return finalPath; + switch (runtimeArch) { + case "i386": case "i486": case "i586": + if((finalPath = findVmForArch(runtimeHomeDir, "i386")) != null) return finalPath; + if((finalPath = findVmForArch(runtimeHomeDir, "i486")) != null) return finalPath; + if((finalPath = findVmForArch(runtimeHomeDir, "i586")) != null) return finalPath; + break; + default: + if((finalPath = findVmForArch(runtimeHomeDir, runtimeArch)) != null) return finalPath; + } + return null; + } + + private static void relocateLdLibPath(File vmPath, List extraDirs) { + // Java directory layout: + // .../server/libjvm.so + // .../libjava.so + // and so on. Hotspot itself relies on this we also rely on this. + File vmDir = Objects.requireNonNull(vmPath.getParentFile()); + File libsDir = Objects.requireNonNull(vmDir.getParentFile()); + StringBuilder libPathBuilder = new StringBuilder() + .append(libsDir.getAbsolutePath()).append(":") + .append(NATIVE_LIB_DIR).append(':') + .append(vmDir.getAbsolutePath()).append(':') + .append(new File(libsDir, "jli").getAbsolutePath()); + + if(extraDirs != null) for(String path : extraDirs) { + libPathBuilder.append(':').append(path); + } + + String ldLibPath = libPathBuilder.toString(); + try { + Os.setenv("LD_LIBRARY_PATH", ldLibPath, true); + }catch (ErrnoException e) { + throw new RuntimeException(e); + } + JREUtils.setLdLibraryPath(ldLibPath); + } + + private static void setImmutableEnvVars(File jreHome) { + try { + Os.setenv("POJAV_NATIVEDIR", NATIVE_LIB_DIR, true); + Os.setenv("JAVA_HOME", jreHome.getAbsolutePath(), true); + Os.setenv("HOME", Tools.DIR_GAME_HOME, true); + Os.setenv("TMPDIR", Tools.DIR_CACHE.getAbsolutePath(), true); + }catch (ErrnoException e) { + throw new RuntimeException(e); + } + } + + private static boolean preprocessUserArgs(List args) { + ListIterator iterator = args.listIterator(); + boolean hasJavaAgent = false; + while(iterator.hasNext()) { + String arg = iterator.next(); + switch (arg) { + case "-p": + arg = "--module-path"; + case "--add-reads": + case "--add-exports": + case "--add-opens": + case "--add-modules": + case "--limit-modules": + case "--module-path": + case "--patch-module": + case "--upgrade-module-path": + iterator.remove(); + String argValue = iterator.next(); + iterator.remove(); + iterator.add(arg+"="+argValue); + break; + case "-d32": + case "-d64": + case "-Xint": + case "-XX:+UseTransparentHugePages": + case "-XX:+UseLargePagesInMetaspace": + case "-XX:+UseLargePages": + iterator.remove(); + break; + default: + if(arg.startsWith("-Xms") || arg.startsWith("-Xmx") || arg.startsWith("-XX:ActiveProcessorCount")) iterator.remove(); + if(!hasJavaAgent && arg.startsWith("-javaagent:")) hasJavaAgent = true; + } + } + return hasJavaAgent; + } + + /** + * Start the Java(tm) Virtual Machine. + * @param runtime the Runtime that we're starting. + * @param vmArgs the command line parameters for the virtual machine + * @param classpathEntries the absolute path for each classpath entry + * @param mainClass the application main class + * @param applicationArgs the application arguments + * @throws VMLoadException if an error occurred during VM loading + */ + public static void startJvm(Runtime runtime, List vmArgs, List classpathEntries, String mainClass, List applicationArgs) throws VMLoadException{ + File runtimeHomeDir = MultiRTUtils.getRuntimeHome(runtime.name); + File vmPath = findVmPath(runtimeHomeDir, runtime.arch); + if(vmPath == null) { + throw new VMLoadException("Unable to find the Java VM", 0, -1); + } + + boolean hasJavaAgent = preprocessUserArgs(vmArgs); + List runtimeArgs = new ArrayList<>(); + if(getCacioJavaArgs(runtimeArgs,runtime.javaVersion == 8)) hasJavaAgent = true; + runtimeArgs.addAll(getJavaArgs(runtimeHomeDir.getAbsolutePath(), vmArgs)); + + + runtimeArgs.add("-XX:ActiveProcessorCount=" + java.lang.Runtime.getRuntime().availableProcessors()); + StringBuilder classpathBuilder = new StringBuilder().append("-Djava.class.path="); + boolean first = true; + for(String entry : classpathEntries) { + if(first) first = false; + else classpathBuilder.append(':'); + classpathBuilder.append(entry); + } + runtimeArgs.add(classpathBuilder.toString()); + + //JREUtils.initializeHooks(); + + setImmutableEnvVars(runtimeHomeDir); + relocateLdLibPath(vmPath, null); + + nativeLoadJVM(vmPath.getAbsolutePath(), runtimeArgs.toArray(new String[0]), mainClass, applicationArgs.toArray(new String[0]), hasJavaAgent); + } + + public static native boolean nativeLoadJVM(String vmPath, String[] javaArgs, String mainClass, String[] appArgs, boolean hasJavaAgents) throws VMLoadException; + public static native void nativeSetupExit(Context context); +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java new file mode 100644 index 0000000000..857e954f58 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java @@ -0,0 +1,51 @@ +package net.kdt.pojavlaunch.utils.jre; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; + +import net.kdt.pojavlaunch.ShowErrorActivity; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.lifecycle.ContextExecutorTask; + +import git.artdeell.mojo.R; + +public class RuntimeSelectionException extends Exception implements ContextExecutorTask { + // Do not change. Android really hates when this value changes for some reason. + private static final long serialVersionUID = -7482301619612640658L; + public static final int RUNTIME_STATE_INSTALLATION_FAILED = 0; + public static final int RUNTIME_STATE_SELECTION_FAILED = 1; + public static final int RUNTIME_STATE_INTERNAL_RUNTIME_MISSING = 2; + private final int mRuntimeState; + private final int mRuntimeVersion; + + public RuntimeSelectionException(int mRuntimeState, int mRuntimeRequiredVersion) { + this.mRuntimeState = mRuntimeState; + this.mRuntimeVersion = mRuntimeRequiredVersion; + } + + @Override + public void executeWithActivity(Activity activity) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.runtime_error_title); + int msgString; + switch (mRuntimeState) { + case RUNTIME_STATE_INSTALLATION_FAILED: msgString = R.string.runtime_error_install_failed; break; + case RUNTIME_STATE_INTERNAL_RUNTIME_MISSING: msgString = R.string.runtime_error_missing; break; + case RUNTIME_STATE_SELECTION_FAILED: msgString = R.string.multirt_nocompatiblert; break; + default: throw new RuntimeException("Unknown runtime state"); + } + builder.setMessage(activity.getString(msgString, mRuntimeVersion)); + builder.setPositiveButton(android.R.string.ok, (d,i)->{}); + if(mRuntimeState == RUNTIME_STATE_INSTALLATION_FAILED || getCause() != null) { + builder.setNegativeButton(R.string.error_show_more, (d, i)-> + Tools.showError(activity, R.string.runtime_error_title, getCause(), activity instanceof ShowErrorActivity) + ); + } + ShowErrorActivity.installRemoteDialogHandling(activity, builder); + builder.show(); + } + + @Override + public void executeWithApplication(Context context) {} +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java new file mode 100644 index 0000000000..86dff21f23 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java @@ -0,0 +1,58 @@ +package net.kdt.pojavlaunch.utils.jre; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import git.artdeell.mojo.R; + +public class VMLoadException extends Exception { + private final int loadStep; + private final int errorCode; + public VMLoadException(String errorInfo, int loadStep, int errorCode) { + super(errorInfo); + this.loadStep = loadStep; + this.errorCode = errorCode; + } + + private static int getLoadStepRes(int loadStep) { + switch (loadStep) { + case 0: return R.string.vml_fail_load_runtime; + case 1: return R.string.vml_fail_create_runtime; + case 2: return R.string.vml_fail_find_hooks_native; + case 3: return R.string.vml_fail_find_hooks; + case 4: return R.string.vml_fail_insert_hooks; + case 5: return R.string.vml_fail_load_classpath; + case 6: return R.string.vml_fail_run_main; + default: return R.string.vml_huh; + } + } + + private static int getErrorCodeRes(int errorCode) { + switch (errorCode) { + case 0: return R.string.vml_err_ok; + case -2: return R.string.vml_err_detached; + case -3: return R.string.vml_err_version; + case -4: return R.string.vml_err_nomem; + case -5: return R.string.vml_err_exists; + case -6: return R.string.vml_err_inval; + case -1: + default: return R.string.vml_err_unknown; + } + } + + @NonNull + public String toString(Context context) { + int loadStepRes = getLoadStepRes(loadStep); + switch (loadStep) { + case 0: + return context.getString(loadStepRes, getMessage()); + case 1: + case 4: + return context.getString(loadStepRes, context.getString(getErrorCodeRes(errorCode))); + default: + return context.getString(loadStepRes); + } + + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java new file mode 100644 index 0000000000..9a0b177461 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java @@ -0,0 +1,22 @@ +package net.kdt.pojavlaunch.utils.memory; + +import net.kdt.pojavlaunch.Architecture; + +public class MemoryHoleFinder implements SelfMapsParser.Callback { + private long mPreviousEnd = 0; + private long mLargestHole = -1; + private final long mAddressingLimit = Architecture.getAddressSpaceLimit(); + @Override + public boolean process(long begin, long end, String wholeLine) { + if(begin >= mAddressingLimit) begin = mAddressingLimit; + long holeSize = begin - mPreviousEnd; + if(mLargestHole < holeSize) mLargestHole = holeSize; + if(begin == mAddressingLimit) return false; + mPreviousEnd = end; + return true; + } + + public long getLargestHole() { + return mLargestHole; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java new file mode 100644 index 0000000000..fa7d2252e9 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java @@ -0,0 +1,35 @@ +package net.kdt.pojavlaunch.utils.memory; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Scanner; + +public class SelfMapsParser { + private final Callback mCallback; + public SelfMapsParser(Callback callback) { + mCallback = callback; + } + + public void run() throws IOException, NumberFormatException { + try (FileInputStream fileInputStream = new FileInputStream("/proc/self/maps")) { + Scanner scanner = new Scanner(fileInputStream); + while(scanner.hasNextLine()) { + if(!forEachLine(scanner.nextLine())) break; + } + } + } + + private boolean forEachLine(String line) throws NumberFormatException { + int firstSpaceIndex = line.indexOf(' '); + String addresses = line.substring(0, firstSpaceIndex); + String[] addressArray = addresses.split("-"); + if(addressArray.length < 2) return true; + long begin = Long.parseLong(addressArray[0], 16); + long end = Long.parseLong(addressArray[1], 16); + return mCallback.process(begin, end, line); + } + + public interface Callback { + boolean process(long startAddress, long endAddress, String wholeLine); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java new file mode 100644 index 0000000000..c41e38866b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java @@ -0,0 +1,7 @@ +package net.kdt.pojavlaunch.value; + +import java.util.List; + +public class ExtractSettings { + public List exclude; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java new file mode 100644 index 0000000000..218e6b0a6f --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java @@ -0,0 +1,5 @@ +package net.kdt.pojavlaunch.value; + +public class LibrarySubstitution extends DependentLibrary { + public boolean skip; +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java index 68c871c95b..9757202dc9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java @@ -13,6 +13,7 @@ import android.util.Base64; import androidx.annotation.Keep; +import androidx.annotation.Nullable; import org.apache.commons.io.IOUtils; @@ -45,7 +46,11 @@ void updateSkinFace(String uuid) { } public boolean isLocal(){ - return accessToken.equals("0"); + return false; + } + + public boolean isDemo(){ + return false; } public void updateSkinFace() { @@ -64,7 +69,7 @@ public String save() throws IOException { public static MinecraftAccount parse(String content) throws JsonSyntaxException { return Tools.GLOBAL_GSON.fromJson(content, MinecraftAccount.class); } - + @Nullable public static MinecraftAccount load(String name) { if(!accountExists(name)) return null; try { @@ -88,7 +93,7 @@ public static MinecraftAccount load(String name) { acc.msaRefreshToken = "0"; } return acc; - } catch(IOException | JsonSyntaxException e) { + } catch(NullPointerException | IOException | JsonSyntaxException e) { Log.e(MinecraftAccount.class.getName(), "Caught an exception while loading the profile",e); return null; } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java new file mode 100644 index 0000000000..e37356c449 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java @@ -0,0 +1,58 @@ +package net.kdt.pojavlaunch.value; + +import net.kdt.pojavlaunch.Architecture; + +public class MoJsonRule { + public String action; + public OSDescriptor os; + + public int getPrecedenceLevel() { + if(os == null) return 1; + return 1 + os.getPrecedenceLevel(); + } + + public boolean matches() { + if(os == null) return true; + else return os.matches(); + } + + public static String ruleSetCheck(MoJsonRule[] rules) { + int precedenceLevel = 0; + String action = "disallow"; + for(MoJsonRule rule : rules) { + int ruleLevel = rule.getPrecedenceLevel(); + if(ruleLevel <= precedenceLevel) { + continue; + } + if(rule.matches()) action = rule.action; + precedenceLevel = ruleLevel; + } + return action; + } + + public static class OSDescriptor { + public String name; + public String version; + public String arch; + + public int getPrecedenceLevel() { + int precedence = 0; + if(name != null) precedence += 1; + if(version != null) precedence += 2; + if(arch != null) precedence += 3; + return precedence; + } + + private static boolean propertyMatches(String value, String expected) { + if(value == null) return true; + return value.equals(expected); + } + + public boolean matches() { + // TODO: version matching + return propertyMatches(name, "linux") && + propertyMatches(arch, Architecture.archAsString(Architecture.getDeviceArchitecture())) && + version == null; + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java new file mode 100644 index 0000000000..be0fe9abf8 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java @@ -0,0 +1,13 @@ +package net.kdt.pojavlaunch.value; + +import java.io.File; + +public class NativeLibraryExtractable { + public final File path; + public final ExtractSettings extractInfo; + + public NativeLibraryExtractable(File path, ExtractSettings extractInfo) { + this.path = path; + this.extractInfo = extractInfo; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java new file mode 100644 index 0000000000..06fe2caee1 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java @@ -0,0 +1,21 @@ +package net.kdt.pojavlaunch.value; + +import java.util.HashMap; +import java.util.Map; + +public class SubstitutionMap { + public LibraryMap libraries; + public Map artifactMapping; + + public LibrarySubstitution findSubstitution(String name) { + if(!name.startsWith("org.lwjgl") && !name.startsWith("net.java.jinput")) return null; + + LibrarySubstitution library = libraries.get(name); + if(library != null) return library; + String mapping = artifactMapping.get(name); + if(mapping == null) return null; + return libraries.get(mapping); + } + + public static class LibraryMap extends HashMap {} +} diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 0000000000..988f348dbb --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,26 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int writeReport(byte[] report, boolean feature); + public boolean readReport(byte[] report, boolean feature); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 0000000000..f61518ae38 --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,650 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + public void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted writeReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + if (feature) { + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } else { + //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted readReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + if (feature) { + readCharacteristic(reportCharacteristic); + return true; + } else { + // Not implemented + return false; + } + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 0000000000..27ef9c3e08 --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,690 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + public static HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + public static void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.commit(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mUsbBroadcast, filter); + } + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0b05, // ASUS + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + 0x3537, // GameSir + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); + return; + } + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mBluetoothBroadcast, filter); + } + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + public void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + if (Build.VERSION.SDK_INT >= 33 /* Android 14.0 (U) */) { + Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION); + intent.setPackage(mContext.getPackageName()); + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags)); + } else { + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags)); + } + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public int writeReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.writeReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public boolean readReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "readReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.readReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceReportResponse(int deviceID, byte[] report); +} diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 0000000000..b5de68b686 --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,323 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + public String getIdentifier() { + return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (mConnection == null) { + Log.w(TAG, "writeReport() called with no device connection"); + return -1; + } + + if (feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } else { + int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (res != report.length) { + Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName()); + } + return res; + } + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (mConnection == null) { + Log.w(TAG, "readReport() called with no device connection"); + return false; + } + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceReportResponse(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 0000000000..ffd1e78964 --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,95 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + loadLibrary(libraryName, mContext); + } + + public static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = context.getClassLoader().loadClass("android.content.Context"); + Class stringClass = context.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, context, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 0000000000..a6096a0cc5 --- /dev/null +++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2233 @@ +/* + * This file is part of SDL3 android-project java code. + * Licensed under the zlib license: https://www.libsdl.org/license.php + */ + +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ActivityNotFoundException; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.LocaleList; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 3; + private static final int SDL_MINOR_VERSION = 2; + private static final int SDL_MICRO_VERSION = 20; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unknown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 21) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 18) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unknown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14; + private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18; + private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentRotation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static SDLDummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API14 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + protected static boolean mSDLMainFinished = false; + protected static boolean mActivityCreated = false; + private static SDLFileDialogState mFileDialogState = null; + protected static boolean mDispatchingKeyEvent = false; + + protected static SDLGenericMotionListener_API14 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API14(); + } + } + + return mMotionListener; + } + + /** + * The application entry point, called on a dedicated thread (SDLThread). + * The default implementation uses the getMainSharedObject() and getMainFunction() methods + * to invoke native code from the specified shared library. + * It can be overridden by derived classes. + */ + protected void main() { + String library = SDLActivity.mSingleton.getMainSharedObject(); + String function = SDLActivity.mSingleton.getMainFunction(); + String[] arguments = SDLActivity.mSingleton.getArguments(); + + Log.v("SDL", "Running main function " + function + " from library " + library); + SDLActivity.nativeRunMain(library, function, arguments); + Log.v("SDL", "Finished main function"); + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL3". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL3", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL3", + // "SDL3_image", + // "SDL3_mixer", + // "SDL3_net", + // "SDL3_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib, this); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER); + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + + /* Control activity re-creation */ + if (mSDLMainFinished || mActivityCreated) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (mSDLMainFinished) { + Log.v(TAG, "SDL main() finished"); + } + if (allow_recreate) { + Log.v(TAG, "activity re-created"); + } else { + Log.v(TAG, "activity finished"); + System.exit(0); + return; + } + } + + mActivityCreated = true; + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + + /* Control activity re-creation */ + /* Robustness: check that the native code is run for the first time. + * (Maybe Activity was reset, but not the native code.) */ + { + int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */ + if (run_count != 0) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (allow_recreate) { + Log.v(TAG, "activity re-created // run_count: " + run_count); + } else { + Log.v(TAG, "activity finished // run_count: " + run_count); + System.exit(0); + return; + } + } + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation()); + mCurrentRotation = SDLActivity.getCurrentRotation(); + SDLActivity.onNativeRotationChanged(mCurrentRotation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getNaturalOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity != null) { + Configuration config = activity.getResources().getConfiguration(); + Display display = activity.getWindowManager().getDefaultDisplay(); + int rotation = display.getRotation(); + if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) && + config.orientation == Configuration.ORIENTATION_LANDSCAPE) || + ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) && + config.orientation == Configuration.ORIENTATION_PORTRAIT)) { + result = SDL_ORIENTATION_LANDSCAPE; + } else { + result = SDL_ORIENTATION_PORTRAIT; + } + } + return result; + } + + public static int getCurrentRotation() { + int result = 0; + + Activity activity = (Activity)getContext(); + if (activity != null) { + Display display = activity.getWindowManager().getDefaultDisplay(); + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = 0; + break; + case Surface.ROTATION_90: + result = 90; + break; + case Surface.ROTATION_180: + result = 180; + break; + case Surface.ROTATION_270: + result = 270; + break; + } + } + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onTrimMemory(int level) { + Log.v(TAG, "onTrimMemory()"); + super.onTrimMemory(level); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + + switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + // Use a timeout because: + // C SDLmain() thread might have started (mSDLThread.start() called) + // while the SDL_Init() might not have been called yet, + // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting. + SDLActivity.mSDLThread.join(1000); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) { + /* This is our file dialog */ + String[] filelist = null; + + if (data != null) { + Uri singleFileUri = data.getData(); + + if (singleFileUri == null) { + /* Use Intent.getClipData to get multiple choices */ + ClipData clipData = data.getClipData(); + assert clipData != null; + + filelist = new String[clipData.getItemCount()]; + + for (int i = 0; i < filelist.length; i++) { + String uri = clipData.getItemAt(i).getUri().toString(); + filelist[i] = uri; + } + } else { + /* Only one file is selected. */ + filelist = new String[]{singleFileUri.toString()}; + } + } else { + /* User cancelled the request. */ + filelist = new String[0]; + } + + // TODO: Detect the file MIME type and pass the filter value accordingly. + SDLActivity.onNativeFileDialog(requestCode, filelist, -1); + mFileDialogState = null; + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + mDispatchingKeyEvent = true; + boolean result = super.dispatchKeyEvent(event); + mDispatchingKeyEvent = false; + return result; + } + + public static boolean dispatchingKeyEvent() { + return mDispatchingKeyEvent; + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + protected static final int COMMAND_CHANGE_TITLE = 1; + protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + protected static final int COMMAND_TEXTEDIT_HIDE = 3; + protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + if (Build.VERSION.SDK_INT >= 28 /* Android 9 (Pie) */) { + window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + } + if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ && + Build.VERSION.SDK_INT < 35 /* Android 15 */) { + SDLActivity.onNativeInsetsChanged(0, 0, 0, 0); + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + protected boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native void nativeInitMainThread(); + public static native void nativeCleanupMainThread(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativePen(int penId, int button, int action, float x, float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void nativeSetNaturalOrientation(int orientation); + public static native void onNativeRotationChanged(int rotation); + public static native void onNativeInsetsChanged(int left, int right, int top, int bottom); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + public static native void onNativeDarkModeChanged(boolean enabled); + public static native boolean nativeAllowRecreateActivity(); + public static native int nativeCheckSDLThreadCounter(); + public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed, respecting user orientation lock setting */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full user */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV")) { + return true; + } + return false; + } + + public static boolean isVRHeadset() { + if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) { + return true; + } + if (Build.MANUFACTURER.equals("Pico")) { + return true; + } + return false; + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int input_type; + public int x, y, w, h; + + public ShowTextInputTask(int input_type, int x, int y, int w, int h) { + this.input_type = input_type; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new SDLDummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + mTextEdit.setInputType(input_type); + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int input_type, int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode)) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode)) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + if (SDLActivity.isVRHeadset()) { + // The Oculus Quest controller back button comes in as source mouse, so accept that + } else { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + onNativeKeyDown(keyCode); + + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + nativeAddTouch(device.getId(), device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray