diff --git a/.github/macports.yml b/.github/macports.yml new file mode 100644 index 000000000..976224723 --- /dev/null +++ b/.github/macports.yml @@ -0,0 +1,21 @@ +ports: + - name: libsdl2 + select: [ universal ] + - name: libpng + select: [ universal ] + - name: glew + select: [ universal ] + - name: libzip + select: [ universal ] + - name: nlohmann-json + select: [ universal ] + - name: tinyxml2 + select: [ universal ] + - name: libogg + select: [ universal ] + - name: libopus + select: [ universal ] + - name: opusfile + select: [ universal ] + - name: libvorbis + select: [ universal ] \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index bc69ac562..faae15853 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -13,6 +13,10 @@ concurrency: cancel-in-progress: true jobs: + # The engine assets are the one thing the app cannot build on-device: packing + # them needs the Torch executable, and the NDK build has no host toolchain. + # Build it here and ship the result as an APK asset. The game's own assets are + # extracted from the user's ROM on-device. generate-port-o2r: runs-on: ubuntu-22.04 steps: @@ -20,7 +24,7 @@ jobs: with: submodules: true - name: Install dependencies - run: sudo apt-get install gcc g++ git cmake ninja-build lsb-release + run: sudo apt-get update && sudo apt-get install -y gcc g++ git cmake ninja-build lsb-release - name: ccache uses: hendrikmuhs/ccache-action@v1.2.18 with: @@ -58,58 +62,69 @@ jobs: - uses: actions/checkout@v4 with: submodules: true - + - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - + - name: Setup Android SDK uses: android-actions/setup-android@v3 - - - name: Install NDK 28 + with: + # the default "tools" package is no longer served and fails the step + packages: '' + + - name: Install NDK run: | - echo "y" | ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;28.2.12604990" || \ - echo "y" | ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;28.0.12433566" - + echo "y" | sdkmanager --install "ndk;30.0.15729638" + - name: Install dependencies - run: sudo apt-get install -y ninja-build - + run: sudo apt-get update && sudo apt-get install -y ninja-build + + # The repository root, not the assets folder: the packaging task in + # android/app/build.gradle.kts bundles this into gamedata.zip itself. - name: Download starship.o2r uses: actions/download-artifact@v4 with: name: starship.o2r - path: android/app/src/main/assets - + path: . + - name: Configure Gradle permissions run: chmod +x android/gradlew - + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Build Debug APK if: matrix.build-type == 'debug' run: | cd android/ - ./gradlew assembleDebug -P elfBuildType=RelWithDebInfo + ./gradlew assembleDebug mv app/build/outputs/apk/debug/app-debug.apk ../starship-debug.apk - + + # Without a keystore the release build falls back to the debug key, so this + # step is allowed to be skipped on forks that have no secrets configured. - name: Setup signing keystore if: matrix.build-type == 'release' && env.KEYSTORE_FILE != '' run: | - echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > android/app/starship-release-key.keystore - echo "STARSHIP_STORE_FILE=starship-release-key.keystore" >> android/keystore.properties - echo "STARSHIP_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> android/keystore.properties - echo "STARSHIP_KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> android/keystore.properties - echo "STARSHIP_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> android/keystore.properties + echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > android/app/release.keystore + test -s android/app/release.keystore || (echo "Failed to create keystore file" && exit 1) env: KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} - + - name: Build Release APK if: matrix.build-type == 'release' run: | cd android/ - ./gradlew assembleRelease -P elfBuildType=RelWithDebInfo + ./gradlew assembleRelease mv app/build/outputs/apk/release/app-release.apk ../starship-release.apk - + env: + KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE != '' && 'release.keystore' || '' }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + - name: Upload Debug APK if: matrix.build-type == 'debug' uses: actions/upload-artifact@v4 @@ -117,7 +132,7 @@ jobs: name: starship-debug-apk path: starship-debug.apk retention-days: 7 - + - name: Upload Release APK if: matrix.build-type == 'release' uses: actions/upload-artifact@v4 @@ -125,6 +140,7 @@ jobs: name: starship-release-apk path: starship-release.apk retention-days: 30 - - + - name: Clean up keystore + if: always() + run: rm -f android/app/release.keystore diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index f70c52e23..569dcca20 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -6,24 +6,18 @@ on: jobs: build: - runs-on: macOS-latest + runs-on: macos-14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - name: Install dependencies - run: brew install sdl2 libpng glew ninja cmake libzip nlohmann-json tinyxml2 spdlog vorbis-tools + - uses: melusina-org/setup-macports@v1 + with: + parameters: '.github/macports.yml' + - uses: hendrikmuhs/ccache-action@v1.2 + with: + key: macos # ccache-macos - name: Build run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release - cmake --build build-cmake -j - - name: Create Package - run: | - mkdir starship-release - mv build-cmake/Starship starship-release/ - - name: Publish packaged artifacts - uses: actions/upload-artifact@v4 - with: - name: starship-mac-x64 - path: starship-release - retention-days: 1 \ No newline at end of file + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" + cmake --build build-cmake -j \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a3a11da7b..f5fbae7f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -75,17 +75,21 @@ jobs: build-macos: needs: generate-port-o2r - runs-on: macOS-latest + runs-on: macos-14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - name: Install dependencies - run: brew install sdl2 libpng glew ninja cmake libzip nlohmann-json tinyxml2 spdlog vorbis-tools + - uses: melusina-org/setup-macports@v1 + with: + parameters: '.github/macports.yml' + - uses: hendrikmuhs/ccache-action@v1.2 + with: + key: macos # ccache-macos - name: Build run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release - cmake --build build-cmake --config Release -j3 + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" + cmake --build build-cmake -j - name: Download starship.o2r uses: actions/download-artifact@v4 with: @@ -93,17 +97,16 @@ jobs: path: ./build-cmake - name: Create Package run: | - mkdir starship-release - mv build-cmake/Starship starship-release/ - mv build-cmake/starship.o2r starship-release/ - mv config.yml starship-release/ - mv assets starship-release/ - wget -O starship-release/gamecontrollerdb.txt https://github.com/mdqinc/SDL_GameControllerDB/blob/master/gamecontrollerdb.txt + (cd build-cmake && cpack) + mv _packages/*.dmg Starship.dmg + mv README.md readme.txt - name: Publish packaged artifacts uses: actions/upload-artifact@v4 with: - name: starship-mac-x64 - path: starship-release + name: Starship-mac + path: | + Starship.dmg + readme.txt build-linux: needs: generate-port-o2r @@ -181,7 +184,7 @@ jobs: cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release cmake --build build-cmake --config Release -j3 (cd build-cmake && cpack -G External) - wget -O gamecontrollerdb.txt https://github.com/mdqinc/SDL_GameControllerDB/blob/master/gamecontrollerdb.txt + wget -O gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt mv README.md readme.txt mv build-cmake/*.appimage starship.appimage - name: Upload build @@ -210,12 +213,12 @@ jobs: sudo apt-get remove -y cmake git -C libultraship remote add nx https://github.com/Net64DD/libultraship.git git -C libultraship fetch nx - git -C libultraship checkout nx/main-nx + git -C libultraship checkout 09dfab5fb2a9a047a6e268dc9db2daad9b2ce5f0 wget https://github.com/Kitware/CMake/releases/download/v3.28.3/cmake-3.28.3-linux-x86_64.sh -O /tmp/cmake.sh sudo sh /tmp/cmake.sh --prefix=/usr/local/ --exclude-subdir cmake -H. -Bbuild-switch -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/Switch.cmake cmake --build build-switch --config Release -j3 - wget -O gamecontrollerdb.txt https://github.com/mdqinc/SDL_GameControllerDB/blob/master/gamecontrollerdb.txt + wget -O gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt mv README.md readme.txt mv build-switch/*.nro Starship.nro - name: Upload build @@ -226,4 +229,4 @@ jobs: Starship.nro config.yml assets - gamecontrollerdb.txt \ No newline at end of file + gamecontrollerdb.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89b01250a..105cbe004 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,8 @@ jobs: build-release: needs: generate-port-o2r runs-on: ubuntu-24.04 + permissions: + contents: write steps: - uses: actions/checkout@v4 with: @@ -70,17 +72,18 @@ jobs: uses: actions/download-artifact@v4 with: name: starship.o2r - path: android/app/src/main/assets - - - name: Update version in build.gradle + path: . + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + # the default "tools" package is no longer served and fails the step + packages: '' + + - name: Install NDK run: | - sed -i "s/versionName \".*\"/versionName \"${{ github.event.inputs.version }}\"/" android/app/build.gradle - # Increment version code - CURRENT_CODE=$(grep "versionCode" android/app/build.gradle | sed 's/.*versionCode \([0-9]*\).*/\1/') - NEW_CODE=$((CURRENT_CODE + 1)) - sed -i "s/versionCode $CURRENT_CODE/versionCode $NEW_CODE/" android/app/build.gradle - echo "Updated to version ${{ github.event.inputs.version }} with code $NEW_CODE" - + echo "y" | sdkmanager --install "ndk;30.0.15729638" + - name: Configure Gradle permissions run: chmod +x android/gradlew @@ -90,27 +93,30 @@ jobs: - name: Setup signing keystore if: env.KEYSTORE_FILE != '' run: | - echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > android/app/starship-release-key.keystore - echo "STARSHIP_STORE_FILE=starship-release-key.keystore" >> android/keystore.properties - echo "STARSHIP_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> android/keystore.properties - echo "STARSHIP_KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> android/keystore.properties - echo "STARSHIP_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> android/keystore.properties + echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > android/app/release.keystore + test -s android/app/release.keystore || (echo "Failed to create keystore file" && exit 1) env: KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} - - - name: Build Release - run: | - cd android/ - ./gradlew clean - ./gradlew assembleRelease -P elfBuildType=RelWithDebInfo --no-daemon - + - name: Verify APK signing run: | - if [ -f android/app/starship-release-key.keystore ]; then + if [ -s android/app/release.keystore ]; then echo "✅ APK will be signed with release keystore" else - echo "⚠️ APK will be signed with debug keystore (unsigned release)" + echo "⚠️ No keystore secret configured; the release APK falls back to the debug key" fi + + - name: Build Release + run: | + cd android/ + ./gradlew assembleRelease --no-daemon \ + -PstarshipVersionName="${{ github.event.inputs.version }}" \ + -PstarshipVersionCode="${{ github.run_number }}" + env: + KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE != '' && 'release.keystore' || '' }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - name: Prepare release files run: | @@ -133,14 +139,11 @@ jobs: ### 📱 Android Release [!IMPORTANT] - **💾 Saves Now Loading!** - **🎥 Immersive View!** - **🔥 Mods now working!** Put your mods in the `mods/` folder. - - **What's New:** - - Optimized release build with R8 code shrinking - - Improved performance and reduced APK size - - Enhanced file management with SAF integration + **🎮 No desktop setup needed!** The app converts your ROM on the device + itself — no more running Torch on a PC and copying `sf64.o2r` across. + **📂 No storage permission needed.** The ROM is read once through the + system file picker and nothing else on your device is touched. + **🔥 Mods and saves managers** are built into the in-game menu. ### 📦 Downloads - **APK**: `starship-v${{ github.event.inputs.version }}.apk` - Direct install for Android devices @@ -149,13 +152,13 @@ jobs: 1. Download the APK file 2. Enable "Install from unknown sources" in Android settings 3. Install the APK on your Android device - 4. Use [Torch](https://github.com/izzy2lost/Torch/releases) to extract your Star Fox 64 ROM - 5. Launch Starship and select your game folder + 4. Launch Starship and pick your own Star Fox 64 (US) `.z64` ROM + 5. Wait for the one-time extraction to finish — the game starts by itself ### ✅ Requirements - Android 7.0 (API 24) or higher - ARM64 or ARMv7 processor - - Star Fox 64 ROM (US 1.0 or 1.1) + - Star Fox 64 ROM (US 1.0 or 1.1), supplied by you ### 🔒 Security SHA256 checksums are provided in `checksums.txt` @@ -168,3 +171,7 @@ jobs: prerelease: ${{ github.event.inputs.prerelease }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean up keystore + if: always() + run: rm -f android/app/release.keystore diff --git a/.github/workflows/switch.yml b/.github/workflows/switch.yml index 5fe784c37..07126f8dc 100644 --- a/.github/workflows/switch.yml +++ b/.github/workflows/switch.yml @@ -20,12 +20,12 @@ jobs: sudo apt-get remove -y cmake git -C libultraship remote add nx https://github.com/Net64DD/libultraship.git git -C libultraship fetch nx - git -C libultraship checkout nx/main-nx + git -C libultraship checkout 09dfab5fb2a9a047a6e268dc9db2daad9b2ce5f0 wget https://github.com/Kitware/CMake/releases/download/v3.28.3/cmake-3.28.3-linux-x86_64.sh -O /tmp/cmake.sh sudo sh /tmp/cmake.sh --prefix=/usr/local/ --exclude-subdir cmake -H. -Bbuild-switch -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/Switch.cmake cmake --build build-switch --config Release -j3 - wget -O gamecontrollerdb.txt https://github.com/mdqinc/SDL_GameControllerDB/blob/master/gamecontrollerdb.txt + wget -O gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt mv README.md readme.txt mv build-switch/*.nro Starship.nro - name: Upload build diff --git a/.gitignore b/.gitignore index 9e00e9f32..4ec9281ff 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ cmake-build-*/ .idea/ .vs build* +# ...but not the Gradle build scripts, which "build*" would otherwise swallow. +!build.gradle +!build.gradle.kts logs/ /mods/ starship.cfg.json diff --git a/.gitmodules b/.gitmodules index 00dc73a51..372345845 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ path = tools/Torch url = https://github.com/HarbourMasters/Torch +[submodule "libultraship"] + path = libultraship + url = https://github.com/Kenix3/libultraship.git diff --git a/Brewfile b/Brewfile new file mode 100644 index 000000000..ce9675fae --- /dev/null +++ b/Brewfile @@ -0,0 +1,8 @@ +brew "sdl2" +brew "libpng" +brew "glew" +brew "libzip" +brew "nlohmann-json" +brew "tinyxml2" +brew "spdlog" +brew "vorbis-tools" \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 08841876a..32659c3dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,11 +51,6 @@ set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT ${PROJECT set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_DEBUGGER_WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) endif() -if(NOT EXISTS ${CMAKE_BINARY_DIR}/config.yml) - execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/config.yml" "${CMAKE_BINARY_DIR}/config.yml") - execute_process(COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets/" "${CMAKE_BINARY_DIR}/assets/") -endif() - if (MSVC) set(CPP "${CMAKE_C_COMPILER}" "/EP") else() @@ -172,6 +167,14 @@ list(FILTER ALL_FILES EXCLUDE REGEX "src/sys/sys_timer.c") list(FILTER ALL_FILES EXCLUDE REGEX "src/sys/sys_fault.c") list(FILTER ALL_FILES EXCLUDE REGEX "src/mods/object_ram.c") +# Ahead of both the game target below and add_subdirectory(libultraship) further +# down: the include_directories() it adds only reach targets declared after it, +# and its OVERRIDE_FIND_PACKAGE declarations are what libultraship's +# find_package(... REQUIRED) calls resolve against. +if(ANDROID) + include(cmake/dependencies/android.cmake) +endif() + if (CMAKE_SYSTEM_NAME STREQUAL "iOS") set(IOS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/libultraship/ios) @@ -284,7 +287,17 @@ include_directories( ${dr_libs_SOURCE_DIR} ) -add_subdirectory(libultraship ${CMAKE_CURRENT_SOURCE_DIR}/libultraship) +# The binary dir is normally libultraship's own source dir, which puts its build +# outputs (libImGui.a, libstb.a) in the source tree. On a single-architecture +# build that is only untidy. Android builds every ABI from the same source tree, +# so both would write those archives to the same path and clobber each other — +# the arm64 link then fails on armeabi-v7a objects, or vice versa, depending on +# which finished last. Give it a binary dir of its own here. +if(ANDROID) + add_subdirectory(libultraship ${CMAKE_CURRENT_BINARY_DIR}/libultraship) +else() + add_subdirectory(libultraship ${CMAKE_CURRENT_SOURCE_DIR}/libultraship) +endif() add_dependencies(${PROJECT_NAME} libultraship) target_link_libraries(${PROJECT_NAME} PRIVATE libultraship) @@ -616,10 +629,12 @@ if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") > ) else() - if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64") - set(CPU_OPTION -msse2 -mfpmath=sse) - elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") - set(CPU_OPTION -mcpu=native) + if(NOT DEFINED CPU_OPTION) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64") + set(CPU_OPTION -msse2 -mfpmath=sse) + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") + set(CPU_OPTION -mcpu=native) + endif() endif() target_compile_options(${PROJECT_NAME} PRIVATE @@ -650,7 +665,24 @@ if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") endif() endif() -if(NOT CMAKE_SYSTEM_NAME STREQUAL "NintendoSwitch") +# On Android these ride into the APK as assets and are unpacked to the app's +# external files directory on first run, so there is nothing useful next to the +# .so to copy them to (see android/app/build.gradle.kts and GameAssets.kt). +if(NOT ANDROID) +add_custom_command( + TARGET ${PROJECT_NAME} POST_BUILD + COMMENT "Copying asset yamls..." + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/config.yml" "$/config.yml" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets/" "$/assets/" +) +endif() + +# Android has no host toolchain available inside the NDK build, so the Torch +# executable these targets drive cannot be built here. starship.o2r is built on +# the CI host and shipped as an APK asset instead, and sf64.o2r is generated +# on-device by the Torch library that is linked into the game (see +# src/port/android/AndroidBridge.cpp). +if(NOT CMAKE_SYSTEM_NAME STREQUAL "NintendoSwitch" AND NOT ANDROID) include(ExternalProject) ExternalProject_Add(TorchExternal PREFIX TorchExternal @@ -679,6 +711,9 @@ add_custom_target( COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/starship.o2r" "${CMAKE_BINARY_DIR}/starship.o2r" ) +find_program(CURL NAMES curl DOC "Path to the curl program. Used to download files.") +execute_process(COMMAND ${CURL} -sSfL https://raw.githubusercontent.com/gabomdq/SDL_GameControllerDB/master/gamecontrollerdb.txt -o ${CMAKE_BINARY_DIR}/gamecontrollerdb.txt OUTPUT_VARIABLE RESULT) + if("${CMAKE_SYSTEM_NAME}" STREQUAL "Linux") install(FILES "${CMAKE_BINARY_DIR}/starship.o2r" DESTINATION . COMPONENT ${PROJECT_NAME}) endif() @@ -702,6 +737,17 @@ add_custom_target(CreateOSXIcons ) add_dependencies(${PROJECT_NAME} CreateOSXIcons) configure_file("${CMAKE_SOURCE_DIR}/Info.plist" "${CMAKE_BINARY_DIR}/Info.plist" COPYONLY) + INSTALL(TARGETS ${PROJECT_NAME} DESTINATION ../MacOS COMPONENT Starship) + INSTALL(FILES ${CMAKE_BINARY_DIR}/gamecontrollerdb.txt DESTINATION ../MacOS COMPONENT Starship) + INSTALL(FILES ${CMAKE_BINARY_DIR}/starship.o2r DESTINATION ../Resources COMPONENT Starship) + INSTALL(FILES ${CMAKE_BINARY_DIR}/config.yml DESTINATION ../Resources COMPONENT Starship) + INSTALL(DIRECTORY ${CMAKE_BINARY_DIR}/assets/ DESTINATION ../Resources/assets COMPONENT Starship) + + # Fix bundle to include and relink all dependencies + install(CODE " + include(BundleUtilities) + fixup_bundle(\"\${CMAKE_INSTALL_PREFIX}/../MacOS/Starship\" \"\" \"${dirs}\") + ") endif() set_property(TARGET ${PROJECT_NAME} PROPERTY APPIMAGE_DESKTOP_FILE_TERMINAL YES) @@ -709,13 +755,17 @@ set_property(TARGET ${PROJECT_NAME} PROPERTY APPIMAGE_DESKTOP_FILE "${CMAKE_SOUR set_property(TARGET ${PROJECT_NAME} PROPERTY APPIMAGE_ICON_FILE "${CMAKE_SOURCE_DIR}/logo.png") endif() -if(CMAKE_SYSTEM_NAME MATCHES "Linux") - set(CPACK_GENERATOR "External") -elseif(CMAKE_SYSTEM_NAME MATCHES "Windows|NintendoSwitch|CafeOS") - set(CPACK_GENERATOR "ZIP") -elseif(CMAKE_SYSTEM_NAME MATCHES "Darwin") - set(CPACK_GENERATOR "Bundle") -endif() +# Gradle packages the Android build into an APK; CPack has nothing to do there, +# and none of the generators below apply to it. +if(NOT ANDROID) + if(CMAKE_SYSTEM_NAME MATCHES "Linux") + set(CPACK_GENERATOR "External") + elseif(CMAKE_SYSTEM_NAME MATCHES "Windows|NintendoSwitch|CafeOS") + set(CPACK_GENERATOR "ZIP") + elseif(CMAKE_SYSTEM_NAME MATCHES "Darwin") + set(CPACK_GENERATOR "Bundle") + endif() -set(CPACK_PROJECT_CONFIG_FILE ${CMAKE_SOURCE_DIR}/cmake/configure-packaging.cmake) -include(cmake/packaging.cmake) + set(CPACK_PROJECT_CONFIG_FILE ${CMAKE_SOURCE_DIR}/cmake/configure-packaging.cmake) + include(cmake/packaging.cmake) +endif() diff --git a/Info.plist b/Info.plist index 39fb6bc13..c0f51ab9f 100644 --- a/Info.plist +++ b/Info.plist @@ -33,6 +33,11 @@ public.app-category.games LSMinimumSystemVersion 10.15 + LSEnvironment + + SHIP_HOME + ~/Library/Application Support/com.Starship.Starship + LSArchitecturePriority arm64 diff --git a/README.md b/README.md index 75c9d0b09..83fd5e4d9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ [comment]: <> (Todo: Make Light Mode Image) [comment]: <> (Todo: Make Dark Mode Image) - + # Starship Lead Developers: * [SonicDcer](https://www.github.com/sonicdcer) * [Lywx](https://www.github.com/kiritodv) -## Discord +## Website & Discord -Official Discord: https://discord.com/invite/shipofharkinian +Official Website: https://www.harbourmasters.org/ If you're having any trouble after reading through this `README`, feel free ask for help in the Starship Support text channels. Please keep in mind that we do not condone piracy. @@ -83,7 +83,7 @@ If you want to manually compile Starship, please consult the [building instructi If you want to playtest a continuous integration build, you can find them at the links below. Keep in mind that these are for playtesting only, and you will likely encounter bugs and possibly crashes. * [Windows](https://nightly.link/HarbourMasters/Starship/workflows/main/main/starship-windows.zip) -* [macOS](https://nightly.link/HarbourMasters/Starship/workflows/main/main/starship-mac-x64.zip) +* [macOS](https://nightly.link/HarbourMasters/Starship/workflows/main/main/Starship-mac.zip) * [Linux](https://nightly.link/HarbourMasters/Starship/workflows/main/main/Starship-linux.zip) * [Switch](https://nightly.link/HarbourMasters/Starship/workflows/main/main/Starship-switch.zip) diff --git a/Starship.desktop b/Starship.desktop index 566ac94ff..fec9ca63f 100644 --- a/Starship.desktop +++ b/Starship.desktop @@ -1,9 +1,9 @@ [Desktop Entry] Version=1.0 -Name=2SHIP +Name=Starship Exec=Starship Terminal=false Icon=logo Type=Application Categories=Game; -X-AppImage-Integrate=false \ No newline at end of file +X-AppImage-Integrate=false diff --git a/android/.gitignore b/android/.gitignore index a25d1f865..a48523be7 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -27,6 +27,10 @@ render.experimental.xml *.jks *.keystore +# Release signing credentials (see app/build.gradle.kts) +key.properties +keystore.properties + # Google Services (e.g. APIs or Firebase) google-services.json diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index c38fccc41..000000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,156 +0,0 @@ -def buildAsLibrary = project.hasProperty('BUILD_AS_LIBRARY'); -def buildAsApplication = !buildAsLibrary -if (buildAsApplication) { - apply plugin: 'com.android.application' -} -else { - apply plugin: 'com.android.library' -} - -// Load keystore properties -def keystorePropertiesFile = rootProject.file("keystore.properties") -def keystoreProperties = new Properties() -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace("com.starship.android") - buildFeatures { - buildConfig true - } - - // Signing configuration - signingConfigs { - release { - if (keystorePropertiesFile.exists()) { - storeFile file(keystoreProperties['STARSHIP_STORE_FILE']) - storePassword keystoreProperties['STARSHIP_STORE_PASSWORD'] - keyAlias keystoreProperties['STARSHIP_KEY_ALIAS'] - keyPassword keystoreProperties['STARSHIP_KEY_PASSWORD'] - } - } - } - ndkVersion "27.0.12077973" - compileSdkVersion 36 - defaultConfig { - if (buildAsApplication) { - applicationId "com.starship.android" - } - minSdkVersion 24 - targetSdkVersion 36 - versionCode 6 - versionName "1.0.1" - externalNativeBuild { - cmake { - arguments "-DSDL_SHARED=ON", "-DANDROID_STL=c++_static", "-DHAVE_LD_VERSION_SCRIPT=OFF",'-DUSE_OPENGLES=ON' - abiFilters 'arm64-v8a', 'armeabi-v7a' - } - } - - // Optimize APK size - vectorDrawables { - useSupportLibrary true - } - } - - // ✅ Universal APK: disables split APKs per ABI - splits { - abi { - enable false - } - } - - buildTypes { - debug { - debuggable true - jniDebuggable true - minifyEnabled false - shrinkResources false - } - release { - debuggable false - jniDebuggable false - // Disable minification temporarily to isolate the issue - minifyEnabled false - shrinkResources false - - // Keep debug symbols for crash analysis - ndk { - debugSymbolLevel 'SYMBOL_TABLE' - } - - // Sign the release build - if (keystorePropertiesFile.exists()) { - signingConfig signingConfigs.release - } - } - } - applicationVariants.all { variant -> - tasks["merge${variant.name.capitalize()}Assets"] - .dependsOn("externalNativeBuild${variant.name.capitalize()}") - } - if (!project.hasProperty('EXCLUDE_NATIVE_LIBS')) { - sourceSets.main { - jniLibs.srcDir 'libs' - } - externalNativeBuild { - cmake { - path '../../CMakeLists.txt' - version "3.31.5" - } - } - } - lintOptions { - abortOnError false - checkReleaseBuilds false - } - - // Additional optimizations - packagingOptions { - // Exclude unnecessary files to reduce APK size - excludes += [ - 'META-INF/DEPENDENCIES', - 'META-INF/LICENSE', - 'META-INF/LICENSE.txt', - 'META-INF/NOTICE', - 'META-INF/NOTICE.txt', - 'META-INF/*.kotlin_module' - ] - // Pick first occurrence of duplicate files - pickFirsts += ['**/libc++_shared.so', '**/libjsc.so'] - } - - - - if (buildAsLibrary) { - libraryVariants.all { variant -> - variant.outputs.each { output -> - def outputFile = output.outputFile - if (outputFile != null && outputFile.name.endsWith(".aar")) { - def fileName = "org.libsdl.app.aar"; - output.outputFile = new File(outputFile.parent, fileName); - } - } - } - } -} - -dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'androidx.core:core:1.7.0' // Use the latest version - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.appcompat:appcompat:1.6.1' -} - -task wrapper(type: Wrapper) { - gradleVersion = '8.9' -} - -task prepareKotlinBuildScriptModel { -} - - - - diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 000000000..6d87166a8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,246 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.security.MessageDigest +import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +val repositoryRoot: File = rootProject.projectDir.parentFile + +// One source of truth for the version: the CMake project declaration. +val projectVersion: String = + Regex("""project\s*\([^)]*VERSION\s+([0-9.]+)""") + .find(File(repositoryRoot, "CMakeLists.txt").readText()) + ?.groupValues?.get(1) + ?: "0.0.0" + +// Release signing: android/key.properties locally, the environment on CI. The +// file holds passwords, so it is gitignored and never read into the build +// output. +val keystoreProperties = Properties().apply { + val file = rootProject.file("key.properties") + if (file.isFile) file.inputStream().use { load(it) } +} + +// With neither configured — a fork's CI, or a fresh clone — the release build +// falls back to the debug key so it still produces an installable APK rather +// than failing on a keystore nobody has. +// +// Blank counts as absent: CI hands these down as secrets, and an unset secret +// arrives as an empty string rather than nothing at all. Testing only for null +// would take an empty value as a configured keystore and fail on file(""). +private fun String?.orAbsent(): String? = this?.takeIf(String::isNotBlank) + +val keystoreFromFile: String? = keystoreProperties.getProperty("storeFile").orAbsent() +val keystoreFromEnv: String? = System.getenv("KEYSTORE_FILE").orAbsent() +val hasReleaseKeystore = keystoreFromFile != null || keystoreFromEnv != null + +// Torch runs on the device to turn the user's ROM into sf64.o2r, and it reads +// its extraction recipes off the filesystem. They ride into the APK in one zip +// alongside the engine archive; the launcher unpacks it into the app's external +// files directory on first run (see GameAssets.kt). The digest beside it is what +// the launcher compares against to decide whether to unpack again. +abstract class PackGameData : DefaultTask() { + + // config.yml points Torch at "assets/yaml//", so these have to + // land under that same relative path on the device. + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val recipes: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val config: RegularFileProperty + + // A collection rather than an InputFile so that a missing archive reaches + // the explanation below instead of Gradle's own input-validation failure. + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val engineArchive: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun pack() { + val engine = engineArchive.files.singleOrNull()?.takeIf(File::isFile) + ?: throw GradleException( + "starship.o2r not found at ${engineArchive.asPath} — build the GeneratePortO2R " + + "target on a desktop platform (or download the CI artifact) before building the APK." + ) + + val recipesRoot = recipes.get().asFile + val entries = buildList { + add("config.yml" to config.get().asFile) + add(engine.name to engine) + recipesRoot.walkTopDown().filter(File::isFile).forEach { + add("assets/${it.relativeTo(recipesRoot).invariantSeparatorsPath}" to it) + } + }.sortedBy { it.first } + + val target = outputDir.get().asFile + target.mkdirs() + val zipFile = File(target, "gamedata.zip") + + // Sorted, at a fixed timestamp: a reproducible zip means a stable + // digest, which means an app update only re-unpacks on the device when + // the contents actually changed. + ZipOutputStream(zipFile.outputStream().buffered()).use { zip -> + entries.forEach { (name, file) -> + zip.putNextEntry(ZipEntry(name).apply { time = FIXED_ENTRY_TIME }) + file.inputStream().use { it.copyTo(zip) } + zip.closeEntry() + } + } + + val digest = MessageDigest.getInstance("MD5").digest(zipFile.readBytes()) + File(target, "gamedata.version").writeText(digest.joinToString("") { "%02x".format(it) }) + } + + private companion object { + // 1980-02-01T00:00:00Z, the same instant Gradle's own archive tasks use + // for this. Anything earlier risks falling before the 1980 floor that a + // zip entry's DOS timestamp can represent. + const val FIXED_ENTRY_TIME = 318211200000L + } +} + +// Wired through the variant API rather than added as a source-set srcDir. The +// srcDir form looks equivalent and is not: the asset merge takes the path but +// not the dependency on the task that fills it, so a clean checkout silently +// packages an APK whose assets are missing everything below — and the +// missing-archive check above never runs to say so. +androidComponents { + onVariants { variant -> + val packGameData = + tasks.register("pack${variant.name.replaceFirstChar(Char::titlecase)}GameData") { + description = "Bundles the Torch recipes and the engine archive into the APK." + recipes.set(File(repositoryRoot, "assets")) + config.set(File(repositoryRoot, "config.yml")) + engineArchive.from(File(repositoryRoot, "starship.o2r")) + } + variant.sources.assets?.addGeneratedSourceDirectory(packGameData, PackGameData::outputDir) + } +} + +android { + namespace = "com.starship.android" + compileSdk = 36 + ndkVersion = "30.0.15729638" + + defaultConfig { + applicationId = "com.starship.android" + minSdk = 24 + targetSdk = 36 + + // Overridable from the release workflow, which knows the tag it is + // cutting. Previously that workflow rewrote this file with sed, which + // broke the moment the file changed shape. + versionCode = (findProperty("starshipVersionCode") as String?)?.toInt() ?: 7 + versionName = (findProperty("starshipVersionName") as String?) ?: projectVersion + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + + externalNativeBuild { + cmake { + arguments += listOf( + "-DANDROID_STL=c++_shared", + "-DUSE_OPENGLES=ON", + "-DSDL_SHARED=ON", + "-DSDL_STATIC=OFF", + "-DHAVE_LD_VERSION_SCRIPT=OFF", + // Forced for every variant, debug included. The root + // CMakeLists sets no -O at all for Debug, so the debug + // variant compiled the game *and Torch* at -O0 — and Torch + // now runs on the device. Extraction is heavy STL work over + // a 12MB ROM; unoptimised it does not appear to finish, + // which reads as a hang rather than as slowness. + "-DCMAKE_BUILD_TYPE=Release" + ) + targets += "Starship" + } + } + } + + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + if (keystoreFromFile != null) { + // A relative path in key.properties reads against the file's + // own directory, which is what someone editing it would + // expect. + storeFile = rootProject.file(keystoreFromFile) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } else { + storeFile = file(keystoreFromEnv!!) + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + } + } + + buildTypes { + debug { + // No isJniDebuggable: the native side is built Release regardless + // (see the CMAKE_BUILD_TYPE argument above), so there would be + // nothing useful to attach to. + applicationIdSuffix = ".debug" + } + release { + // The APK is almost entirely native code and assets, so shrinking + // the tiny Kotlin layer buys nothing and only risks stripping the + // classes the JNI bridge looks up by name. + isMinifyEnabled = false + signingConfig = signingConfigs.getByName(if (hasReleaseKeystore) "release" else "debug") + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } + } + } + + externalNativeBuild { + cmake { + path = File(repositoryRoot, "CMakeLists.txt") + // libultraship needs CMake >= 3.24; the NDK-bundled 3.22.1 is too old. + version = "3.30.3+" + } + } + + // Universal APK: one artifact covering both ABIs rather than a split per ABI. + splits { + abi { + isEnable = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint { + abortOnError = false + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") +} diff --git a/android/app/gradle/wrapper/gradle-wrapper.jar b/android/app/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7454180f2..000000000 Binary files a/android/app/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/app/gradle/wrapper/gradle-wrapper.properties b/android/app/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e750102e0..000000000 --- a/android/app/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/android/app/gradlew b/android/app/gradlew deleted file mode 100644 index 1b6c78733..000000000 --- a/android/app/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/android/app/gradlew.bat b/android/app/gradlew.bat deleted file mode 100644 index 107acd32c..000000000 --- a/android/app/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 998c8ec25..a47e612ed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,9 @@ - - + - - - - - - - - - - - - + android:required="false" /> - + - - - - - - - - - - - - - - - + + + + + + + + + android:label="@string/app_name" + android:process=":launcher" + android:screenOrientation="sensorLandscape" + android:theme="@android:style/Theme.Material.NoActionBar"> + + + - - + android:name=".ModsActivity" + android:exported="false" + android:label="@string/mods_button" + android:screenOrientation="sensorLandscape" + android:theme="@android:style/Theme.Material.NoActionBar" /> + + diff --git a/android/app/src/main/java/com/starship/android/AssetCopyUtil.java b/android/app/src/main/java/com/starship/android/AssetCopyUtil.java deleted file mode 100644 index de32a6225..000000000 --- a/android/app/src/main/java/com/starship/android/AssetCopyUtil.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.starship.android; - -import android.content.Context; -import android.content.res.AssetManager; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -public class AssetCopyUtil { - - public static void copyAssetsToExternal(Context context, String assetsFolderPath, String externalFolderPath) throws IOException { - AssetManager assetManager = context.getAssets(); - String[] assetFiles = assetManager.list(assetsFolderPath); - - for (String assetFile : assetFiles) { - String assetPath = assetsFolderPath + File.separator + assetFile; - String externalPath = externalFolderPath + File.separator + assetFile; - - if (assetManager.list(assetPath).length > 0) { - // It's a directory - // Check if the directory exists in the external storage - File externalDir = new File(externalPath); - if (!externalDir.exists()) { - externalDir.mkdirs(); // Create the directory if it doesn't exist - } - - // Recursively copy contents of the directory - copyAssetsToExternal(context, assetPath, externalPath); - } else { - // It's a file - File externalFile = new File(externalPath); - if (!externalFile.exists()) { - // Check if the file exists in the external storage - InputStream in = null; - OutputStream out = null; - - try { - in = assetManager.open(assetPath); - out = new FileOutputStream(externalPath); - - byte[] buffer = new byte[1024]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - - } finally { - if (in != null) { - in.close(); - } - if (out != null) { - out.close(); - } - } - } - } - } - } - - public static void copyDirectory(File sourceDir, File targetDir) throws IOException { - if (!targetDir.exists()) { - targetDir.mkdirs(); - } - for (File file : sourceDir.listFiles()) { - File dest = new File(targetDir, file.getName()); - if (file.isDirectory()) { - copyDirectory(file, dest); - } else { - copyFile(file, dest); - } - } - } - - public static void copyFile(File source, File dest) throws IOException { - try (InputStream in = new FileInputStream(source); - OutputStream out = new FileOutputStream(dest)) { - byte[] buf = new byte[64*1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/starship/android/ControllerButtons.java b/android/app/src/main/java/com/starship/android/ControllerButtons.java deleted file mode 100644 index f0f2d6a47..000000000 --- a/android/app/src/main/java/com/starship/android/ControllerButtons.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.starship.android; - -public class ControllerButtons { - // Xbox Buttons - public static final int BUTTON_A = 0; - public static final int BUTTON_B = 1; - public static final int BUTTON_X = 2; - public static final int BUTTON_Y = 3; - public static final int BUTTON_LB = 9; // Left Bumper - public static final int BUTTON_RB = -5; // Right Bumper - public static final int BUTTON_BACK = 4; - public static final int BUTTON_START = 6; - public static final int BUTTON_MENU = 111; // Escape/Menu button (KeyEvent.KEYCODE_ESCAPE) - public static final int BUTTON_DPAD_UP = 11; - public static final int BUTTON_DPAD_DOWN = 12; - public static final int BUTTON_DPAD_LEFT = 13; - public static final int BUTTON_DPAD_RIGHT = 14; - - // Xbox Axis (Joysticks and Triggers) - public static final int AXIS_LX = 0; // Left stick X - public static final int AXIS_LY = 1; // Left stick Y - public static final int AXIS_RX = 2; // Right stick X - public static final int AXIS_RY = 3; // Right stick Y - public static final int AXIS_LT = -2; // Left Trigger (negative axis) - public static final int AXIS_RT = -4; // Right Trigger (negative axis) -} \ No newline at end of file diff --git a/android/app/src/main/java/com/starship/android/ControllerButtons.kt b/android/app/src/main/java/com/starship/android/ControllerButtons.kt new file mode 100644 index 000000000..abd74c549 --- /dev/null +++ b/android/app/src/main/java/com/starship/android/ControllerButtons.kt @@ -0,0 +1,27 @@ +package com.starship.android + +/** + * Indices into the virtual SDL game controller created by the native bridge. + * + * They follow SDL's own `SDL_GameControllerButton` / `SDL_GameControllerAxis` + * ordering. A negative button id addresses the axis with that (positive) index + * instead, which is how the shoulder triggers are driven from a plain on/off + * button in the overlay. + */ +object ControllerButtons { + const val BUTTON_A = 0 + const val BUTTON_B = 1 + const val BUTTON_X = 2 + const val BUTTON_Y = 3 + const val BUTTON_BACK = 4 + const val BUTTON_START = 6 + const val BUTTON_LB = 9 + const val BUTTON_RB = -5 + + const val AXIS_LX = 0 + const val AXIS_LY = 1 + const val AXIS_RX = 2 + const val AXIS_RY = 3 + const val AXIS_LT = -2 + const val AXIS_RT = -4 +} diff --git a/android/app/src/main/java/com/starship/android/DialogActivity.java b/android/app/src/main/java/com/starship/android/DialogActivity.java deleted file mode 100644 index dc7f38131..000000000 --- a/android/app/src/main/java/com/starship/android/DialogActivity.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.starship.android; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Intent; -import android.os.Bundle; - -public class DialogActivity extends Activity { - public static final String EXTRA_TITLE = "title"; - public static final String EXTRA_MESSAGE = "message"; - public static final String EXTRA_POSITIVE_BUTTON = "positive_button"; - public static final String EXTRA_NEGATIVE_BUTTON = "negative_button"; - public static final String EXTRA_CANCELABLE = "cancelable"; - public static final String EXTRA_DIALOG_TYPE = "dialog_type"; - - public static final int DIALOG_TYPE_FOLDER_PROMPT = 1; - public static final int DIALOG_TYPE_FILE_NOT_FOUND = 2; - public static final int DIALOG_TYPE_COPY_COMPLETE = 3; - public static final int DIALOG_TYPE_FILE_READY = 4; - - public static final int RESULT_POSITIVE = RESULT_OK; - public static final int RESULT_NEGATIVE = RESULT_CANCELED; - public static final int RESULT_FOLDER_PICKER = 100; - public static final int RESULT_TORCH_DOWNLOAD = 101; - public static final int RESULT_FILE_PICKER = 102; - public static final int RESULT_RESTART = 103; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - Intent intent = getIntent(); - String title = intent.getStringExtra(EXTRA_TITLE); - String message = intent.getStringExtra(EXTRA_MESSAGE); - String positiveButton = intent.getStringExtra(EXTRA_POSITIVE_BUTTON); - String negativeButton = intent.getStringExtra(EXTRA_NEGATIVE_BUTTON); - boolean cancelable = intent.getBooleanExtra(EXTRA_CANCELABLE, true); - int dialogType = intent.getIntExtra(EXTRA_DIALOG_TYPE, 0); - - AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.RoundedDialog); - builder.setTitle(title); - builder.setMessage(message); - builder.setCancelable(cancelable); - - // Set up buttons based on dialog type - switch (dialogType) { - case DIALOG_TYPE_FOLDER_PROMPT: - builder.setPositiveButton("Select Folder", (d, w) -> { - setResult(RESULT_FOLDER_PICKER); - finish(); - }); - break; - - case DIALOG_TYPE_FILE_NOT_FOUND: - builder.setPositiveButton("Download Torch App", (d, w) -> { - android.util.Log.i("DialogActivity", "Torch download button pressed"); - setResult(RESULT_TORCH_DOWNLOAD); - finish(); - }); - builder.setNegativeButton("Select sf64.o2r File", (d, w) -> { - android.util.Log.i("DialogActivity", "File picker button pressed"); - setResult(RESULT_FILE_PICKER); - finish(); - }); - break; - - case DIALOG_TYPE_COPY_COMPLETE: - builder.setPositiveButton("Restart", (d, w) -> { - setResult(RESULT_RESTART); - finish(); - }); - builder.setNegativeButton("Later", (d, w) -> { - setResult(RESULT_NEGATIVE); - finish(); - }); - break; - - case DIALOG_TYPE_FILE_READY: - builder.setPositiveButton("Restart", (d, w) -> { - setResult(RESULT_RESTART); - finish(); - }); - break; - - default: - builder.setPositiveButton(positiveButton != null ? positiveButton : "OK", (d, w) -> { - setResult(RESULT_POSITIVE); - finish(); - }); - if (negativeButton != null) { - builder.setNegativeButton(negativeButton, (d, w) -> { - setResult(RESULT_NEGATIVE); - finish(); - }); - } - break; - } - - AlertDialog dialog = builder.create(); - dialog.setOnDismissListener(d -> finish()); - dialog.show(); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/starship/android/GameAssets.kt b/android/app/src/main/java/com/starship/android/GameAssets.kt new file mode 100644 index 000000000..bb291ec29 --- /dev/null +++ b/android/app/src/main/java/com/starship/android/GameAssets.kt @@ -0,0 +1,188 @@ +package com.starship.android + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.security.MessageDigest +import java.util.zip.ZipInputStream + +/** + * Everything the game needs on disk, and how it gets there. + * + * libultraship resolves every runtime path on Android through + * `SDL_AndroidGetExternalStoragePath()`, i.e. `getExternalFilesDir(null)`. That + * is also somewhere the user can reach with a file manager, which is what makes + * dropping mods in possible, so everything lives there: + * + * Android/media/com.starship.android/ + * baserom.us.rev1.z64 the user's ROM, copied in by the launcher + * config.yml, assets/ Torch's extraction recipes, unpacked from the APK + * starship.o2r engine assets, built on CI and shipped in the APK + * sf64.o2r generated on-device from the ROM + * mods/ user mods (.o2r, .zip or plain folders) + * + * The unpacking happens here rather than inside the engine because Torch needs + * config.yml and assets/ on disk before the game process starts, not after. + * The zip itself is assembled by android/app/build.gradle.kts. + */ +object GameAssets { + + // Named for the engine's own mobile fallback (GameExtractor.cpp), so a + // re-extraction triggered from inside the game finds it too. Torch hashes + // the contents, so the name says nothing about which revision it holds. + const val ROM_NAME = "baserom.us.rev1.z64" + + /** + * The ROMs that yield sf64.o2r, by SHA-1. + * + * Only the US revisions are here on purpose. config.yml routes JP, EU and CN + * to `mods/sf64jp.o2r` and friends — those are language overlays layered on + * top of the US archive, not a base game — so extracting one of them would + * finish without ever producing the archive the engine boots from. + */ + val SUPPORTED_ROMS = mapOf( + "09f0d105f476b00efa5303a3ebc42e60a7753b7a" to "Star Fox 64 (U) (V1.1)", + "f7475fb11e7e6830f82883412638e8390791ab87" to "Star Fox 64 (U) (V1.1) (Uncompressed)", + "d8b1088520f7c5f81433292a9258c1184afa1457" to "Star Fox 64 (U) (V1.0)", + "63b69f0ef36306257481afc250f9bc304c7162b2" to "Star Fox 64 (U) (V1.0) (Uncompressed)" + ) + + private const val TAG = "GameAssets" + private const val GAME_ARCHIVE = "sf64.o2r" + private const val CONTROLLER_DB = "gamecontrollerdb.txt" + private const val TORCH_HASHES = "torch.hash.yml" + private const val GAMEDATA_ZIP = "gamedata.zip" + private const val GAMEDATA_VERSION = "gamedata.version" + private const val STAMP = ".gamedata.version" + + // Deprecated since API 30, but still the way to reach Android/media, which Syncthing can sync. + @Suppress("DEPRECATION") + fun gameDir(context: Context): File = + (context.externalMediaDirs.firstOrNull { it != null } ?: context.filesDir).also { it.mkdirs() } + + fun romFile(context: Context) = File(gameDir(context), ROM_NAME) + + fun gameArchive(context: Context) = File(gameDir(context), GAME_ARCHIVE) + + fun modsDir(context: Context) = File(gameDir(context), "mods") + + fun isExtracted(context: Context) = gameArchive(context).length() > 0 + + /** + * Unpacks the APK-bundled extraction inputs into [gameDir]. + * + * Re-runs whenever the packaged zip changes, so an app update ships new + * recipes and a new starship.o2r without the user clearing data. Returns an + * error message, or null when the directory is ready. + */ + fun stageBundledAssets(context: Context): String? { + val target = gameDir(context) + val stamp = File(target, STAMP) + + return try { + val expected = context.assets.open(GAMEDATA_VERSION).use { it.readBytes().decodeToString() } + if (stamp.takeIf { it.isFile }?.readText() == expected) { + return null + } + + context.assets.open(GAMEDATA_ZIP).use { input -> + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + val destination = File(target, entry.name).canonicalFile + + // An entry named ../../something would otherwise write + // outside the game directory. + if (!destination.path.startsWith(target.canonicalPath + File.separator)) { + Log.w(TAG, "Skipping suspicious zip entry ${entry.name}") + zip.closeEntry() + continue + } + + if (entry.isDirectory) { + destination.mkdirs() + } else { + destination.parentFile?.mkdirs() + FileOutputStream(destination).use { output -> zip.copyTo(output, COPY_BUFFER_BYTES) } + } + zip.closeEntry() + } + } + } + + // Users are meant to edit this one, so only seed it once. + val controllerDb = File(target, CONTROLLER_DB) + if (!controllerDb.isFile) { + copyAsset(context, CONTROLLER_DB, controllerDb) + } + + val mods = modsDir(context) + mods.mkdirs() + if (mods.list().isNullOrEmpty()) { + // Only a hint for the player, and nothing reads it back, so it + // must not fail an extraction that has otherwise succeeded. + try { + copyAsset(context, "mods/place_mods_here.txt", File(mods, "place_mods_here.txt")) + } catch (error: IOException) { + Log.w(TAG, "Could not write the mods placeholder", error) + } + } + + // New recipes mean the previous archive is stale, and Torch skips + // work whose inputs it believes are unchanged. + gameArchive(context).delete() + File(target, TORCH_HASHES).delete() + + stamp.writeText(expected) + null + } catch (error: IOException) { + Log.e(TAG, "Could not unpack the bundled assets", error) + "Could not unpack the bundled game files: ${error.message}" + } + } + + /** + * Runs Torch over [romFile] to produce sf64.o2r. Blocking and slow — expect + * a minute or more on older hardware. Returns an error message, or null on + * success. + */ + fun generateGameArchive(context: Context): String? { + val dir = gameDir(context) + return nativeGenerateGameArchive(romFile(context).absolutePath, dir.absolutePath, dir.absolutePath) + } + + fun sha1Of(file: File): String { + val digest = MessageDigest.getInstance("SHA-1") + file.inputStream().use { input -> + val buffer = ByteArray(COPY_BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private const val COPY_BUFFER_BYTES = 1 shl 17 + + private fun copyAsset(context: Context, assetPath: String, target: File) { + target.parentFile?.mkdirs() + context.assets.open(assetPath).use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + } + + private external fun nativeGenerateGameArchive(rom: String, sourceDir: String, destDir: String): String? + + init { + // libStarship.so carries both the game and the Torch extractor. + System.loadLibrary("SDL2") + System.loadLibrary("Starship") + } +} diff --git a/android/app/src/main/java/com/starship/android/LauncherActivity.kt b/android/app/src/main/java/com/starship/android/LauncherActivity.kt new file mode 100644 index 000000000..a37873dfd --- /dev/null +++ b/android/app/src/main/java/com/starship/android/LauncherActivity.kt @@ -0,0 +1,212 @@ +package com.starship.android + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import java.io.File +import java.io.FileOutputStream +import kotlin.concurrent.thread + +/** + * First-run flow: unpack the bundled game files, take a ROM from the user, and + * turn it into sf64.o2r before handing off to the game. + * + * No game data is shipped in the APK — the ROM stays on the device and the + * extraction runs here, on demand. + */ +class LauncherActivity : ComponentActivity() { + + private lateinit var status: TextView + private lateinit var chooseButton: Button + private lateinit var progress: ProgressBar + + private val pickRom = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri != null) importRom(uri) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + buildUi() + prepare() + } + + /** + * Unpacks the bundled game files, then decides what still has to happen: + * nothing (start the game), extract an already-imported ROM, or ask for one. + * + * The unpacking moves megabytes out of the APK, so it cannot run on the main + * thread. + */ + private fun prepare() { + setBusy(true, getString(R.string.launcher_preparing)) + + thread(name = "sf64-prepare") { + val stagingError = GameAssets.stageBundledAssets(this) + + runOnUiThread { + when { + // Nothing else will work without the bundled files, so + // leave the button disabled rather than invite a retry. + stagingError != null -> { + setBusy(false, stagingError) + chooseButton.isEnabled = false + } + GameAssets.isExtracted(this) -> startGame() + GameAssets.romFile(this).isFile -> extractRom() + else -> setBusy(false, romInstructions) + } + } + } + } + + private fun buildUi() { + val padding = (24 * resources.displayMetrics.density).toInt() + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(padding, padding, padding, padding) + setBackgroundColor(Color.rgb(8, 16, 28)) + } + + status = TextView(this).apply { + setTextColor(Color.WHITE) + textSize = 16f + gravity = Gravity.CENTER + } + chooseButton = Button(this).apply { + text = getString(R.string.launcher_choose_rom) + setOnClickListener { pickRom.launch(arrayOf("*/*")) } + } + progress = ProgressBar(this).apply { visibility = View.GONE } + + // The failure messages can run long, and the launcher is fixed to + // landscape, so let the text scroll rather than push the button away. + val scroller = ScrollView(this).apply { + addView( + status, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + ) + } + + root.addView(scroller, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + root.addView(chooseButton) + root.addView(progress) + setContentView(root) + root.padForSystemBars() + } + + private fun importRom(uri: Uri) { + setBusy(true, getString(R.string.launcher_verifying)) + + thread(name = "sf64-rom-import") { + val result = runCatching { + val target = GameAssets.romFile(this) + val partial = File(target.parentFile, "${GameAssets.ROM_NAME}.part") + partial.delete() + + contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "The document provider did not open the file." } + FileOutputStream(partial).use { output -> + input.copyTo(output, COPY_BUFFER_BYTES) + output.fd.sync() + } + } + + val sha1 = GameAssets.sha1Of(partial) + if (sha1 !in GameAssets.SUPPORTED_ROMS) { + partial.delete() + error( + "That is not a supported Star Fox 64 ROM.\n\n" + + "Supported:\n" + + GameAssets.SUPPORTED_ROMS.entries.joinToString("\n") { " ${it.value} — ${it.key}" } + + "\n\nGot $sha1" + ) + } + + if (target.exists() && !target.delete()) { + error("Could not replace the previous ROM.") + } + check(partial.renameTo(target)) { "Could not store the ROM in the app's files directory." } + } + + runOnUiThread { + result.fold( + onSuccess = { extractRom() }, + onFailure = { + setBusy(false, "${it.message ?: it}\n\n$romInstructions") + } + ) + } + } + } + + private fun extractRom() { + setBusy(true, getString(R.string.launcher_extracting)) + + thread(name = "sf64-extract") { + val error = GameAssets.generateGameArchive(this) + + runOnUiThread { + if (error == null) { + startGame() + } else { + setBusy(false, "$error\n\n$romInstructions") + } + } + } + } + + /** + * Both long-running steps keep the screen awake: the extraction has no + * progress to report and can outlast the display timeout, and being killed + * part-way through leaves a truncated archive behind. + */ + private fun setBusy(busy: Boolean, message: String) { + status.text = message + chooseButton.isEnabled = !busy + progress.visibility = if (busy) View.VISIBLE else View.GONE + if (busy) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun startGame() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + + // This activity has a process to itself and the game runs in the app's + // main one, so ending this one costs the game nothing and hands back + // everything the launcher touched: after an extraction that is Torch + // still holding the ROM and every decoded asset, and otherwise it is + // still the game library mapped in just to reach that one JNI call. + // startActivity() has already reached the activity manager by this + // point, so the game starts either way. + Runtime.getRuntime().exit(0) + } + + /** Spelled out from the real path so debug builds don't point at the release one. */ + private val romInstructions: String + get() = "Select your own Star Fox 64 (US) .z64 ROM. It is verified, kept in this app's private " + + "storage and converted into the game's asset archive on this device — no game data ships " + + "with the app.\n\n" + + "Mods go in ${GameAssets.modsDir(this)}." + + private companion object { + const val COPY_BUFFER_BYTES = 1 shl 17 + } +} diff --git a/android/app/src/main/java/com/starship/android/MainActivity.java b/android/app/src/main/java/com/starship/android/MainActivity.java deleted file mode 100644 index 967047b91..000000000 --- a/android/app/src/main/java/com/starship/android/MainActivity.java +++ /dev/null @@ -1,1065 +0,0 @@ -package com.starship.android; - -import org.libsdl.app.SDLActivity; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.util.Log; -import android.widget.Toast; -import android.app.AlertDialog; -import java.io.File; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.IOException; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.util.concurrent.CountDownLatch; -import androidx.documentfile.provider.DocumentFile; - -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.FrameLayout; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.widget.ImageView; -import android.view.KeyEvent; -import android.content.pm.ActivityInfo; -import android.os.Build; -import android.view.WindowInsets; -import android.view.WindowInsetsController; - -public class MainActivity extends SDLActivity { -static { - System.loadLibrary("Starship"); -} - -// ===== Constants / Prefs ===== -private static final String PREFS = "com.starship.android.prefs"; -private static final String KEY_USER_FOLDER_URI = "user_folder_uri"; -private static final String TAG = "MainActivity"; -private static final int REQ_PICK_FOLDER = 1001; -private static final int REQ_PICK_SF64 = 1002; - -// ===== State ===== -SharedPreferences preferences; -private static final CountDownLatch setupLatch = new CountDownLatch(1); -private Uri userFolderUri; // Persisted SAF tree URI - -// ===== Native methods ===== -public native void attachController(); -public native void detachController(); -public native void setButton(int button, boolean value); -public native void setCameraState(int axis, float value); -public native void setAxis(int axis, short value); - -// ===== Save dir for the engine (internal only; no extra subfolder) ===== -public static String getSaveDir() { - Context ctx = SDLActivity.getContext(); - File internal = ctx.getFilesDir(); // /data/data//files - if (!internal.exists()) internal.mkdirs(); - Log.i(TAG, "getSaveDir -> " + internal.getAbsolutePath()); - return internal.getAbsolutePath(); -} - -private Uri getUserFolderUri() { - String s = preferences.getString(KEY_USER_FOLDER_URI, null); - return (s != null) ? Uri.parse(s) : null; -} - -private void setUserFolderUri(Uri uri) { - preferences.edit().putString(KEY_USER_FOLDER_URI, uri != null ? uri.toString() : null).apply(); -} - -private String getUserFolderPath() { - if (userFolderUri == null) return null; - try { - String treeId = DocumentsContract.getTreeDocumentId(userFolderUri); - // Check if it's external storage - if (treeId.startsWith("primary:")) { - String relativePath = treeId.substring("primary:".length()); - File externalStorage = Environment.getExternalStorageDirectory(); - return new File(externalStorage, relativePath).getAbsolutePath(); - } - Log.w(TAG, "Could not extract path from URI: " + userFolderUri); - return null; - } catch (Exception e) { - Log.e(TAG, "Error extracting path from URI", e); - return null; - } -} - -private void syncModsFromUserFolder() { - try { - DocumentFile userRoot = DocumentFile.fromTreeUri(this, userFolderUri); - if (userRoot == null) return; - - DocumentFile userModsFolder = userRoot.findFile("mods"); - if (userModsFolder == null || !userModsFolder.isDirectory()) { - Log.i(TAG, "No mods folder found in user directory"); - return; - } - - // Prefer external app-specific storage so the engine (which uses SDL_AndroidGetExternalStoragePath) - // can discover mods at /Android/data//files/mods. Fallback to internal if external is unavailable. - File targetRoot = getExternalFilesDir(null); - if (targetRoot == null) { - Log.w(TAG, "External files dir is null, falling back to internal files dir for mods"); - targetRoot = getFilesDir(); - } - File targetModsFolder = new File(targetRoot, "mods"); - if (!targetModsFolder.exists()) { - targetModsFolder.mkdirs(); - } - - // Clear existing mods in target storage first - clearDirectory(targetModsFolder); - - // Copy all files from user mods folder to target mods folder - copyModsRecursively(userModsFolder, targetModsFolder); - Log.i(TAG, "Mods synced from user folder to: " + targetModsFolder.getAbsolutePath()); - } catch (Exception e) { - Log.e(TAG, "Error syncing mods from user folder", e); - } -} - -private void clearDirectory(File dir) { - if (dir.exists() && dir.isDirectory()) { - File[] files = dir.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - clearDirectory(file); - } - file.delete(); - } - } - } -} - -private void copyModsRecursively(DocumentFile sourceDir, File destDir) { - try { - DocumentFile[] files = sourceDir.listFiles(); - if (files == null) return; - - for (DocumentFile file : files) { - String fileName = file.getName(); - if (fileName == null) continue; - - File destFile = new File(destDir, fileName); - if (file.isDirectory()) { - // Create directory and recurse - destFile.mkdirs(); - copyModsRecursively(file, destFile); - } else { - // Copy file - try (InputStream in = getContentResolver().openInputStream(file.getUri()); - FileOutputStream out = new FileOutputStream(destFile)) { - byte[] buf = new byte[8192]; - int r; - while ((r = in.read(buf)) != -1) { - out.write(buf, 0, r); - } - out.flush(); - Log.i(TAG, "Copied mod file: " + fileName); - } catch (IOException e) { - Log.e(TAG, "Failed to copy mod file: " + fileName, e); - } - } - } - } catch (Exception e) { - Log.e(TAG, "Error in copyModsRecursively", e); - } -} - -// ===== Lifecycle ===== -@Override -protected void onCreate(Bundle savedInstanceState) { - preferences = getSharedPreferences(PREFS, Context.MODE_PRIVATE); - userFolderUri = getUserFolderUri(); - - super.onCreate(savedInstanceState); - - // Enable immersive mode (edge-to-edge) - enableImmersiveMode(); - -setupControllerOverlay(); - attachController(); - - // Seed internal directory with assets if they exist (optional) - seedInternalFromAssetsIfPresent(); - - File internal = getFilesDir(); - File external = getExternalFilesDir(null); - Log.i(TAG, "Internal root: " + internal); - Log.i(TAG, "External root: " + external); - - // Check for sf64.o2r in internal storage first - File internalSf64 = new File(internal, "sf64.o2r"); - - // If not in internal, check if it exists in user's chosen folder and copy it - if (!internalSf64.exists() && userFolderUri != null) { - DocumentFile userRoot = DocumentFile.fromTreeUri(this, userFolderUri); - if (userRoot != null) { - DocumentFile userSf64 = userRoot.findFile("sf64.o2r"); - if (userSf64 != null && userSf64.exists()) { - Log.i(TAG, "Found sf64.o2r in user folder, copying to internal storage"); - try (InputStream in = getContentResolver().openInputStream(userSf64.getUri()); - FileOutputStream out = new FileOutputStream(internalSf64)) { - byte[] buf = new byte[8192]; - int r; - while ((r = in.read(buf)) != -1) { - out.write(buf, 0, r); - } - out.flush(); - out.getFD().sync(); - Log.i(TAG, "sf64.o2r copied from user folder to internal storage"); - -} catch (IOException e) { - Log.e(TAG, "Failed to copy sf64.o2r from user folder", e); - } - } - } - } - - // Always sync mods folder from user's chosen folder to internal storage - if (userFolderUri != null) { - syncModsFromUserFolder(); - } - - // Now check if sf64.o2r exists in internal storage - if (!internalSf64.exists()) { - Log.i(TAG, "sf64.o2r not found. Prompting for folder."); - if (userFolderUri == null) { - // First time - need to select folder - promptForUserFolder(); - } else { - // Folder already selected but no ROM - show file not found dialog - showPortraitDialog("sf64.o2r not found in selected folder", - "Pick an existing sf64.o2r file or use Torch to create one. It will be copied to your selected folder.", - DialogActivity.DIALOG_TYPE_FILE_NOT_FOUND); - } - } else { - Log.i(TAG, "sf64.o2r found in internal storage, game should start normally."); - } - - // Signal to C++ that setup is complete - setupLatch.countDown(); -} - -public static void waitForSetupFromNative() { - try { - setupLatch.await(); - } catch (InterruptedException ignored) { - Log.w(TAG, "waitForSetupFromNative interrupted"); - } -} - -// ===== Asset seeding (optional, safe if assets not present) ===== -private boolean assetExists(String name) { - try { - getAssets().open(name).close(); - return true; - } catch (IOException e) { - return false; - } -} - -private boolean assetDirExists(String dir) { - try { - String[] list = getAssets().list(dir); - return list != null && list.length > 0; - } catch (IOException e) { - return false; - } -} - -private void seedInternalFromAssetsIfPresent() { - File internal = getFilesDir(); - - // Prefer the real name: gamecontrollerdb.txt (but accept controllerdb.txt asset if that's what you ship) - File gcdb = new File(internal, "gamecontrollerdb.txt"); - File cdb = new File(internal, "controllerdb.txt"); - if (!gcdb.exists() && !cdb.exists()) { - if (assetExists("gamecontrollerdb.txt")) { - copyAssetFile("gamecontrollerdb.txt", gcdb); - } else if (assetExists("controllerdb.txt")) { - copyAssetFile("controllerdb.txt", cdb); - } else { - Log.i(TAG, "No controller DB asset shipped."); - } - } - - File starship = new File(internal, "starship.o2r"); - Log.i(TAG, "Checking starship.o2r - exists in internal: " + starship.exists() + ", exists in assets: " + assetExists("starship.o2r")); - if (!starship.exists() && assetExists("starship.o2r")) { - Log.i(TAG, "Copying starship.o2r from assets to internal"); - copyAssetFile("starship.o2r", starship); - } else if (!starship.exists()) { - Log.w(TAG, "starship.o2r not found in assets - this might be expected for development builds"); - // List available assets for debugging - try { - String[] assets = getAssets().list(""); - Log.i(TAG, "Available assets:"); - for (String asset : assets) { - Log.i(TAG, " - " + asset); - } - } catch (IOException e) { - Log.e(TAG, "Error listing assets", e); - } - } - - File modsDir = new File(internal, "mods"); - if (!modsDir.exists() && assetDirExists("mods")) { - copyAssetFolderRecursive("mods", modsDir); - } -} - -private void copyAssetFile(String assetName, File destFile) { - try { - File parent = destFile.getParentFile(); - if (parent != null && !parent.exists()) parent.mkdirs(); - - try (InputStream in = getAssets().open(assetName); - FileOutputStream out = new FileOutputStream(destFile)) { - byte[] buffer = new byte[8192]; - int read; - long total = 0; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - total += read; - } - out.flush(); - out.getFD().sync(); - Log.i(TAG, "Seeded asset " + assetName + " (" + total + " bytes) -> " + destFile.getAbsolutePath()); - } - } catch (IOException e) { - Log.e(TAG, "copyAssetFile failed for " + assetName, e); - } -} - -private void copyAssetFolderRecursive(String assetDir, File destDir) { - try { - if (!destDir.exists()) destDir.mkdirs(); - - String[] kids = getAssets().list(assetDir); - if (kids == null) return; - - for (String name : kids) { - String assetPath = assetDir + "/" + name; - String[] sub = getAssets().list(assetPath); - if (sub != null && sub.length > 0) { - copyAssetFolderRecursive(assetPath, new File(destDir, name)); - } else { - copyAssetFile(assetPath, new File(destDir, name)); - } - } - Log.i(TAG, "Seeded asset dir " + assetDir + " -> " + destDir.getAbsolutePath()); - } catch (IOException e) { - Log.e(TAG, "copyAssetFolderRecursive failed for " + assetDir, e); - } -} - -// ===== UI helpers ===== -private void showPortraitDialog(String title, String message, int dialogType) { - Intent intent = new Intent(this, DialogActivity.class); - intent.putExtra(DialogActivity.EXTRA_TITLE, title); - intent.putExtra(DialogActivity.EXTRA_MESSAGE, message); - intent.putExtra(DialogActivity.EXTRA_DIALOG_TYPE, dialogType); - intent.putExtra(DialogActivity.EXTRA_CANCELABLE, false); - startActivityForResult(intent, dialogType); -} - -private void showToast(String msg) { - runOnUiThread(() -> Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()); -} - -private void restartApp() { - try { - Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); - if (i != null) { - i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(i); - finish(); - System.exit(0); - } - } catch (Exception e) { - Log.e(TAG, "restartApp", e); - showToast("Please restart the app manually"); - } -} - -// ===== Folder / File pickers ===== -private void promptForUserFolder() { - showPortraitDialog("Choose Your Folder", - "Select a folder for your sf64.o2r file and mods location. This will be your main Starship folder where you can add mods and manage game files.", - DialogActivity.DIALOG_TYPE_FOLDER_PROMPT); -} - -public void openFolderPicker() { - Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); - startActivityForResult(i, REQ_PICK_FOLDER); -} - -public void openFilePickerForSf64() { - Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT); - i.setType("*/*"); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - startActivityForResult(i, REQ_PICK_SF64); -} - -private void openTorchDownload() { - try { - Intent browser = new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/izzy2lost/Torch/releases")); - Intent chooser = Intent.createChooser(browser, "Open Torch download page with:"); - chooser.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(chooser); - showToast("Opening Torch download. App will close so you can install Torch."); - - // Close the app after a short delay so user can install Torch - new android.os.Handler(getMainLooper()).postDelayed(() -> { - finish(); - System.exit(0); - }, 2000); - } catch (Exception e) { - Log.e(TAG, "openTorchDownload", e); - showToast("Visit: https://github.com/izzy2lost/Torch/releases"); - } -} - -// ===== Activity result ===== -@Override -protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - Log.i(TAG, "onActivityResult: requestCode=" + requestCode + ", resultCode=" + resultCode); - - // Handle DialogActivity results - if (requestCode == DialogActivity.DIALOG_TYPE_FOLDER_PROMPT && resultCode == DialogActivity.RESULT_FOLDER_PICKER) { - Log.i(TAG, "Opening folder picker"); - openFolderPicker(); - return; - } - if (requestCode == DialogActivity.DIALOG_TYPE_FILE_NOT_FOUND) { - if (resultCode == DialogActivity.RESULT_TORCH_DOWNLOAD) { - Log.i(TAG, "Opening Torch download"); - openTorchDownload(); - } else if (resultCode == DialogActivity.RESULT_FILE_PICKER) { - Log.i(TAG, "Opening file picker for sf64"); - openFilePickerForSf64(); - } - return; - } - if ((requestCode == DialogActivity.DIALOG_TYPE_COPY_COMPLETE || requestCode == DialogActivity.DIALOG_TYPE_FILE_READY) - && resultCode == DialogActivity.RESULT_RESTART) { - restartApp(); - return; - } - - // Handle file/folder picker results - if (resultCode != RESULT_OK || data == null) return; - - if (requestCode == REQ_PICK_FOLDER) { - handleFolderSelection(data.getData(), data.getFlags()); - } else if (requestCode == REQ_PICK_SF64) { - handleRomFileSelection(data.getData()); - } -} - -// ===== Folder selection & copy (SAF) ===== -private void handleFolderSelection(Uri treeUri, int returnedFlags) { - if (treeUri == null) { - showToast("No folder selected."); - return; - } - - // Persist read/write — try returned flags; if 0, try explicit - try { - final int permsReturned = returnedFlags & - (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (permsReturned != 0) { - getContentResolver().takePersistableUriPermission(treeUri, permsReturned); - } else { - getContentResolver().takePersistableUriPermission(treeUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - } - } catch (Exception e) { - Log.w(TAG, "takePersistableUriPermission failed (continuing with transient perms): " + e); - } - - setUserFolderUri(treeUri); - userFolderUri = treeUri; - showToast("Folder selected."); - - DocumentFile userRoot = DocumentFile.fromTreeUri(this, treeUri); - if (userRoot == null) { - showToast("Cannot access the selected folder."); - return; - } - - if (!verifyWritable(userRoot)) { - showToast("Can't write there. Try Downloads or a folder you create under Internal storage."); - return; - } - - boolean anyCopied = copyFromBestSourceToSaf(userRoot); - - // Check if sf64.o2r exists in the user's chosen folder and copy it to internal storage - DocumentFile sf64InUserFolder = userRoot.findFile("sf64.o2r"); - if (sf64InUserFolder != null && sf64InUserFolder.exists()) { - // Copy sf64.o2r to internal storage - File internalSf64 = new File(getFilesDir(), "sf64.o2r"); - try (InputStream in = getContentResolver().openInputStream(sf64InUserFolder.getUri()); - FileOutputStream out = new FileOutputStream(internalSf64)) { - byte[] buf = new byte[8192]; - int r; - while ((r = in.read(buf)) != -1) { - out.write(buf, 0, r); - } - out.flush(); - out.getFD().sync(); - Log.i(TAG, "sf64.o2r copied from user folder to internal storage during folder selection"); - - -// Also sync mods - syncModsFromUserFolder(); - - showToast("Files copied. Game should start now."); - } catch (IOException e) { - Log.e(TAG, "Failed to copy sf64.o2r during folder selection", e); - showToast("Failed to copy sf64.o2r: " + e.getMessage()); - } - } else { - runOnUiThread(() -> showPortraitDialog("sf64.o2r not found in selected folder", - "Pick an existing sf64.o2r file or use Torch to create one. It will be copied to your selected folder.", - DialogActivity.DIALOG_TYPE_FILE_NOT_FOUND)); - } -} - -private boolean verifyWritable(DocumentFile dir) { - try { - DocumentFile tmp = dir.createFile("application/octet-stream", ".saf_write_test"); - if (tmp == null) return false; - OutputStream os = getContentResolver().openOutputStream(tmp.getUri(), "w"); - if (os == null) return false; - os.write(1); - os.flush(); - os.close(); - tmp.delete(); - return true; - } catch (Exception e) { - Log.w(TAG, "verifyWritable failed: " + e); - return false; - } -} - -// Choose best source root: -// 1) Internal files dir -// 2) External files dir root (legacy) -// 3) External files dir /Starship (legacy leftover) -private File chooseBestSourceRoot() { - File internal = getFilesDir(); - File external = getExternalFilesDir(null); - File legacy = (external != null) ? new File(external, "Starship") : null; - - boolean hasInternal = hasAnyTarget(internal); - boolean hasExternal = external != null && hasAnyTarget(external); - boolean hasLegacy = legacy != null && hasAnyTarget(legacy); - - Log.i(TAG, "Source check -> internal=" + hasInternal + ", external=" + hasExternal + ", legacy=" + hasLegacy); - - if (hasInternal) return internal; - if (hasExternal) return external; - if (hasLegacy) return legacy; - return internal; // default (will report not found later) -} - -private boolean hasAnyTarget(File root) { - if (root == null) return false; - if (new File(root, "gamecontrollerdb.txt").exists()) return true; - if (new File(root, "controllerdb.txt").exists()) return true; // tolerate alt name - if (new File(root, "starship.o2r").exists()) return true; - File mods = new File(root, "mods"); - return mods.exists() && mods.isDirectory() && mods.listFiles() != null && mods.listFiles().length > 0; -} - -private boolean copyFromBestSourceToSaf(DocumentFile userRoot) { - File srcRoot = chooseBestSourceRoot(); - Log.i(TAG, "Copying from srcRoot=" + srcRoot); - int copied = 0; - - // gamecontrollerdb.txt (prefer this name), fall back to controllerdb.txt if that's what exists - File gcdb = new File(srcRoot, "gamecontrollerdb.txt"); - File cdb = new File(srcRoot, "controllerdb.txt"); - if (gcdb.exists()) { - if (copyFileToTree(gcdb, userRoot, "text/plain")) copied++; - } else if (cdb.exists()) { - if (copyFileToTree(cdb, userRoot, "text/plain")) copied++; - } else Log.w(TAG, "No controller DB at " + srcRoot); - - // starship.o2r - File starship = new File(srcRoot, "starship.o2r"); - if (starship.exists()) { - if (copyFileToTree(starship, userRoot, "application/octet-stream")) copied++; - } else Log.w(TAG, "No starship.o2r at " + srcRoot); - - // mods - File modsSrc = new File(srcRoot, "mods"); - if (modsSrc.exists() && modsSrc.isDirectory()) { - if (copyFolderToTree(modsSrc, userRoot)) copied++; - } else Log.w(TAG, "No mods folder at " + srcRoot); - - if (copied > 0) showToast("Copied " + copied + " item(s) to selected folder."); - else showToast("Nothing copied. Make sure files exist in app storage."); - return copied > 0; -} - -// When user picks sf64.o2r via SAF, copy into INTERNAL (engine reads from here) AND user folder (for persistence) -private void handleRomFileSelection(Uri selectedFileUri) { - if (selectedFileUri == null) { - showToast("No sf64.o2r selected."); - return; - } - - File dest = new File(getFilesDir(), "sf64.o2r"); - showToast("Copying sf64.o2r..."); - - try (InputStream in = getContentResolver().openInputStream(selectedFileUri); - FileOutputStream out = new FileOutputStream(dest)) { - byte[] buf = new byte[8192]; - int r; - long total = 0; - while ((r = in.read(buf)) != -1) { - out.write(buf, 0, r); - total += r; - } - out.flush(); - out.getFD().sync(); - Log.i(TAG, "sf64.o2r copied to internal (" + total + " bytes): " + dest.getAbsolutePath()); - - // Also copy to user's selected folder if one exists - if (userFolderUri != null) { - DocumentFile userRoot = DocumentFile.fromTreeUri(this, userFolderUri); - if (userRoot != null) { - if (copyFileToTree(dest, userRoot, "application/octet-stream")) { - Log.i(TAG, "sf64.o2r also copied to user's selected folder"); - } else { - Log.w(TAG, "Failed to copy sf64.o2r to user's selected folder"); - } - } - } - - runOnUiThread(() -> showPortraitDialog("sf64.o2r ready", - "sf64.o2r copied. Restart to load the game.", - DialogActivity.DIALOG_TYPE_FILE_READY)); - } catch (IOException e) { - Log.e(TAG, "handleRomFileSelection", e); - showToast("Failed to copy sf64.o2r: " + e.getMessage()); - } -} - -// ===== SAF copy helpers ===== -private boolean copyFileToTree(File src, DocumentFile dstParent, String mimeGuess) { - try { - // Overwrite if present - DocumentFile existing = findChild(dstParent, src.getName()); - if (existing != null && existing.isFile()) existing.delete(); - - String mime = (mimeGuess != null) ? mimeGuess : guessMime(src.getName()); - DocumentFile dest = dstParent.createFile(mime, src.getName()); - if (dest == null) { - Log.e(TAG, "Failed to create file in tree: " + src.getName()); - return false; - } - - try (InputStream in = new FileInputStream(src); - OutputStream out = getContentResolver().openOutputStream(dest.getUri(), "w")) { - if (out == null) throw new IOException("Null OutputStream from resolver"); - byte[] buf = new byte[8192]; - int r; - long total = 0; - while ((r = in.read(buf)) != -1) { - out.write(buf, 0, r); - total += r; - } - out.flush(); - Log.i(TAG, "Wrote " + total + " bytes → " + dest.getUri()); - } - Log.i(TAG, "Copied to SAF: " + src.getAbsolutePath() + " → " + dest.getUri()); - return true; - } catch (IOException e) { - Log.e(TAG, "copyFileToTree " + src.getName(), e); - showToast("Failed copying " + src.getName()); - return false; - } -} - -// Returns true if any item was copied -private boolean copyFolderToTree(File srcDir, DocumentFile dstParent) { - boolean any = false; - DocumentFile dstDir = ensureDirectory(dstParent, srcDir.getName()); - if (dstDir == null) return false; - - File[] kids = srcDir.listFiles(); - if (kids == null) return false; - - for (File kid : kids) { - if (kid.isDirectory()) { - if (copyFolderToTree(kid, dstDir)) any = true; - } else { - if (copyFileToTree(kid, dstDir, guessMime(kid.getName()))) any = true; - } - } - return any; -} - -private DocumentFile ensureDirectory(DocumentFile parent, String name) { - DocumentFile existing = findChild(parent, name); - if (existing != null && existing.isDirectory()) return existing; - if (existing != null) existing.delete(); - return parent.createDirectory(name); -} - -private DocumentFile findChild(DocumentFile parent, String name) { - for (DocumentFile f : parent.listFiles()) { - if (name.equals(f.getName())) return f; - } - return null; -} - -private String guessMime(String name) { - String n = name.toLowerCase(); - if (n.endsWith(".txt")) return "text/plain"; - if (n.endsWith(".json")) return "application/json"; - return "application/octet-stream"; -} - -// ================= Controller overlay and touch handling ================= -private Button buttonA, buttonB, buttonX, buttonY; -private Button buttonDpadUp, buttonDpadDown, buttonDpadLeft, buttonDpadRight; -private Button buttonLB, buttonRB, buttonZ, buttonStart, buttonBack, buttonToggle, buttonMenu; -private FrameLayout leftJoystick; -private ImageView leftJoystickKnob; -private View overlayView; - -private void setupControllerOverlay() { - LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); - overlayView = inflater.inflate(R.layout.touchcontrol_overlay, null); - FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ); - overlayView.setLayoutParams(layoutParams); - ViewGroup rootView = (ViewGroup) this.getWindow().getDecorView().findViewById(android.R.id.content); - rootView.addView(overlayView); - - final ViewGroup buttonGroup = overlayView.findViewById(R.id.button_group); - - buttonA = overlayView.findViewById(R.id.buttonA); - buttonB = overlayView.findViewById(R.id.buttonB); - buttonX = overlayView.findViewById(R.id.buttonX); - buttonY = overlayView.findViewById(R.id.buttonY); - - buttonDpadUp = overlayView.findViewById(R.id.buttonDpadUp); - buttonDpadDown = overlayView.findViewById(R.id.buttonDpadDown); - buttonDpadLeft = overlayView.findViewById(R.id.buttonDpadLeft); - buttonDpadRight = overlayView.findViewById(R.id.buttonDpadRight); - - buttonLB = overlayView.findViewById(R.id.buttonLB); - buttonRB = overlayView.findViewById(R.id.buttonRB); - buttonZ = overlayView.findViewById(R.id.buttonZ); - - buttonStart = overlayView.findViewById(R.id.buttonStart); - buttonBack = overlayView.findViewById(R.id.buttonBack); - buttonMenu = overlayView.findViewById(R.id.buttonMenu); - - buttonToggle = overlayView.findViewById(R.id.buttonToggle); - - leftJoystick = overlayView.findViewById(R.id.left_joystick); - leftJoystickKnob = overlayView.findViewById(R.id.left_joystick_knob); - - FrameLayout rightScreenArea = overlayView.findViewById(R.id.right_screen_area); - - addTouchListener(buttonA, ControllerButtons.BUTTON_A); - addTouchListener(buttonB, ControllerButtons.BUTTON_B); - addTouchListener(buttonX, ControllerButtons.BUTTON_X); - addTouchListener(buttonY, ControllerButtons.BUTTON_Y); - - setupCButtons(buttonDpadUp, ControllerButtons.AXIS_RY, 1); - setupCButtons(buttonDpadDown, ControllerButtons.AXIS_RY, -1); - setupCButtons(buttonDpadLeft, ControllerButtons.AXIS_RX, 1); - setupCButtons(buttonDpadRight, ControllerButtons.AXIS_RX, -1); - - addTouchListener(buttonLB, ControllerButtons.BUTTON_LB); - addTouchListener(buttonRB, ControllerButtons.BUTTON_RB); - addTouchListener(buttonZ, ControllerButtons.AXIS_RT); - - addTouchListener(buttonStart, ControllerButtons.BUTTON_START); - addTouchListener(buttonBack, ControllerButtons.BUTTON_BACK); - setupMenuButton(buttonMenu); - - setupJoystick(leftJoystick, leftJoystickKnob, true); - setupLookAround(rightScreenArea); - setupToggleButton(buttonToggle, buttonGroup); -} - -private void enableImmersiveMode() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11+ (API 30+) - WindowInsetsController controller = getWindow().getInsetsController(); - if (controller != null) { - controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); - controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } - } else { - // Android 10 and below - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - ); - } -} - -@Override -public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) { - enableImmersiveMode(); - } -} - -@Override -public void onBackPressed() { - // Show confirmation dialog before exiting - new AlertDialog.Builder(this) - .setTitle("Exit Game") - .setMessage("Are you sure you want to quit?") - .setPositiveButton("Yes", (dialog, which) -> { - // Exit the app - finish(); - System.exit(0); - }) - .setNegativeButton("No", (dialog, which) -> { - // Dismiss dialog and continue playing - dialog.dismiss(); - }) - .setCancelable(true) - .show(); -} - -private void setupMenuButton(Button button) { - button.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - onNativeKeyDown(KeyEvent.KEYCODE_F1); - button.setPressed(true); - // Toggle menu state and controls - MenuOpen = !MenuOpen; - if (MenuOpen) { - DisableAllControls(); - } else { - EnableAllControls(); - } - return true; - case MotionEvent.ACTION_UP: - onNativeKeyUp(KeyEvent.KEYCODE_F1); - button.setPressed(false); - return true; - case MotionEvent.ACTION_CANCEL: - onNativeKeyUp(KeyEvent.KEYCODE_F1); - return true; - } - return false; - } - }); -} - -private void setupToggleButton(Button button, ViewGroup uiGroup) { - boolean isHidden = preferences.getBoolean("controlsVisible", false); - uiGroup.setVisibility(isHidden ? View.INVISIBLE : View.VISIBLE); - button.setOnClickListener(new View.OnClickListener() { - boolean isHidden = false; - @Override - public void onClick(View v) { - if (isHidden) { - uiGroup.setVisibility(View.VISIBLE); - } else { - uiGroup.setVisibility(View.INVISIBLE); - } - preferences.edit().putBoolean("controlsVisible", !isHidden).apply(); - isHidden = !isHidden; - } - }); -} - -private void addTouchListener(Button button, int buttonNum) { - button.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - if (!AllControlsEnabled) return false; - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - setButton(buttonNum, true); - button.setPressed(true); - return true; - case MotionEvent.ACTION_UP: - setButton(buttonNum, false); - button.setPressed(false); - return true; - case MotionEvent.ACTION_CANCEL: - setButton(buttonNum, false); - return true; - } - return false; - } - }); -} - -private void setupCButtons(Button button, int buttonNum, int direction) { - button.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - if (!AllControlsEnabled) return false; - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - setAxis(buttonNum, direction < 0 ? Short.MAX_VALUE : Short.MIN_VALUE); - button.setPressed(true); - return true; - case MotionEvent.ACTION_UP: - setAxis(buttonNum, (short) 0); - button.setPressed(false); - return true; - case MotionEvent.ACTION_CANCEL: - setAxis(buttonNum, (short) 0); - return true; - } - return false; - } - }); -} - -// Control state management -private boolean TouchAreaEnabled = true; -private boolean MenuOpen = false; -private boolean AllControlsEnabled = true; - -private void DisableTouchArea() { - TouchAreaEnabled = false; -} - -private void EnableTouchArea() { - TouchAreaEnabled = true; -} - -private void DisableAllControls() { - AllControlsEnabled = false; - TouchAreaEnabled = false; -} - -private void EnableAllControls() { - AllControlsEnabled = true; - TouchAreaEnabled = true; -} - -private void setupLookAround(FrameLayout rightScreenArea) { - rightScreenArea.setOnTouchListener(new View.OnTouchListener() { - private float lastX = 0; - private float lastY = 0; - private boolean isTouching = false; - - @Override - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - lastX = event.getX(); - lastY = event.getY(); - isTouching = true; - break; - case MotionEvent.ACTION_MOVE: - if (isTouching) { - float deltaX = event.getX() - lastX; - float deltaY = event.getY() - lastY; - lastX = event.getX(); - lastY = event.getY(); - float sensitivityMultiplier = 15; - float rx = (deltaX * sensitivityMultiplier); - float ry = (deltaY * sensitivityMultiplier); - setCameraState(0, rx); - setCameraState(1, ry); - } - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - isTouching = false; - setCameraState(0, 0.0f); - setCameraState(1, 0.0f); - break; - } - return TouchAreaEnabled && AllControlsEnabled; - } - }); -} - -private void setupJoystick(FrameLayout joystickLayout, ImageView joystickKnob, boolean isLeft) { - joystickLayout.post(() -> { - final float joystickCenterX = joystickLayout.getWidth() / 2f; - final float joystickCenterY = joystickLayout.getHeight() / 2f; - - joystickLayout.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - if (!AllControlsEnabled) return false; - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_MOVE: - float deltaX = event.getX() - joystickCenterX; - float deltaY = event.getY() - joystickCenterY; - float maxRadius = joystickLayout.getWidth() / 2f - joystickKnob.getWidth() / 2f; - float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY); - if (distance > maxRadius) { - float scale = maxRadius / distance; - deltaX *= scale; - deltaY *= scale; - } - joystickKnob.setX(joystickCenterX + deltaX - joystickKnob.getWidth() / 2f); - joystickKnob.setY(joystickCenterY + deltaY - joystickKnob.getHeight() / 2f); - - short x = (short) (deltaX / maxRadius * Short.MAX_VALUE); - short y = (short) (deltaY / maxRadius * Short.MAX_VALUE); - setAxis(isLeft ? ControllerButtons.AXIS_LX : ControllerButtons.AXIS_RX, x); - setAxis(isLeft ? ControllerButtons.AXIS_LY : ControllerButtons.AXIS_RY, y); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - joystickKnob.setX(joystickCenterX - joystickKnob.getWidth() / 2f); - joystickKnob.setY(joystickCenterY - joystickKnob.getHeight() / 2f); - setAxis(isLeft ? ControllerButtons.AXIS_LX : ControllerButtons.AXIS_RX, (short) 0); - setAxis(isLeft ? ControllerButtons.AXIS_LY : ControllerButtons.AXIS_RY, (short) 0); - break; - } - return true; - } - }); - }); -} -} \ No newline at end of file diff --git a/android/app/src/main/java/com/starship/android/MainActivity.kt b/android/app/src/main/java/com/starship/android/MainActivity.kt new file mode 100644 index 000000000..01aa9e313 --- /dev/null +++ b/android/app/src/main/java/com/starship/android/MainActivity.kt @@ -0,0 +1,362 @@ +package com.starship.android + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import java.io.File +import org.libsdl.app.SDLActivity +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * The game itself, plus the on-screen controller drawn over the SDL surface. + * + * The overlay drives a virtual SDL joystick (see src/port/android/AndroidBridge.cpp) + * rather than synthesising input events, so it goes through libultraship's normal + * controller mapping and can be remapped in-game like any other pad. + * + * [LauncherActivity] guarantees sf64.o2r exists before this activity starts. + */ +class MainActivity : SDLActivity() { + + private lateinit var preferences: SharedPreferences + private lateinit var modsButton: Button + + private var controlsEnabled = true + private var menuOpen = false + private var controlsHidden = false + + private val handler = Handler(Looper.getMainLooper()) + private val menuWatcher = object : Runnable { + override fun run() { + syncMenuState() + handler.postDelayed(this, MENU_POLL_MS) + } + } + + // org/libsdl/app is kept byte-identical to the SDL release libultraship + // pins, so the game library is named here rather than patched in there. + // SDLActivity refuses to start if the Java glue and libSDL2.so disagree on + // their version, so both have to move together. + override fun getLibraries(): Array = arrayOf("SDL2", "Starship") + + // SDL resolves the engine's directory through this, so it has to agree with GameAssets.gameDir. + override fun getExternalFilesDir(type: String?): File? = + if (type == null) GameAssets.gameDir(this) else super.getExternalFilesDir(type) + + private external fun attachController() + private external fun detachController() + private external fun setButton(button: Int, value: Boolean) + private external fun setAxis(axis: Int, value: Short) + private external fun isMenuOpen(): Boolean + + override fun onCreate(savedInstanceState: Bundle?) { + preferences = getSharedPreferences(PREFS, MODE_PRIVATE) + super.onCreate(savedInstanceState) + setupControllerOverlay() + attachController() + } + + override fun onResume() { + super.onResume() + // Re-sync immediately as well as on the timer, so coming back from the + // mods manager never leaves the overlay showing a stale menu state. + syncMenuState() + handler.removeCallbacks(menuWatcher) + handler.postDelayed(menuWatcher, MENU_POLL_MS) + } + + override fun onPause() { + handler.removeCallbacks(menuWatcher) + super.onPause() + } + + override fun onDestroy() { + handler.removeCallbacks(menuWatcher) + detachController() + super.onDestroy() + } + + /** + * Mirrors the engine's menu state onto the overlay. + * + * The menu is not only driven by the on-screen Menu button: a physical + * keyboard, a gamepad, or the menu dismissing itself all change it without + * the overlay hearing about it. Tracking it with a local flag drifts out of + * sync and leaves the controls dead with the Mods button stuck on screen, + * so the engine is asked instead. + */ + private fun syncMenuState() { + val open = runCatching { isMenuOpen() }.getOrDefault(false) + if (open == menuOpen) return + + menuOpen = open + controlsEnabled = !open + // Managing mods is a settings action, so it stays out of reach until + // the menu is up. + modsButton.visibility = if (open) View.VISIBLE else View.GONE + + if (open) { + releaseAllInputs() + } + } + + /** + * Clears anything the overlay was holding down. Without this, opening the + * menu mid-input leaves that button or stick stuck on in the virtual pad, + * because the touch listeners stop reporting once controls are disabled. + */ + private fun releaseAllInputs() { + for (button in intArrayOf( + ControllerButtons.BUTTON_A, ControllerButtons.BUTTON_B, + ControllerButtons.BUTTON_X, ControllerButtons.BUTTON_Y, + ControllerButtons.BUTTON_LB, ControllerButtons.BUTTON_RB, + ControllerButtons.BUTTON_START, ControllerButtons.BUTTON_BACK, + ControllerButtons.AXIS_RT + )) { + setButton(button, false) + } + for (axis in intArrayOf( + ControllerButtons.AXIS_LX, ControllerButtons.AXIS_LY, + ControllerButtons.AXIS_RX, ControllerButtons.AXIS_RY + )) { + setAxis(axis, 0) + } + } + + private fun setupControllerOverlay() { + val inflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater + val overlay = inflater.inflate(R.layout.touchcontrol_overlay, null).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + findViewById(android.R.id.content).addView(overlay) + + val buttonGroup = overlay.findViewById(R.id.button_group) + + overlay.bindButton(R.id.buttonA, ControllerButtons.BUTTON_A) + overlay.bindButton(R.id.buttonB, ControllerButtons.BUTTON_B) + overlay.bindButton(R.id.buttonX, ControllerButtons.BUTTON_X) + overlay.bindButton(R.id.buttonY, ControllerButtons.BUTTON_Y) + overlay.bindButton(R.id.buttonLB, ControllerButtons.BUTTON_LB) + overlay.bindButton(R.id.buttonRB, ControllerButtons.BUTTON_RB) + overlay.bindButton(R.id.buttonZ, ControllerButtons.AXIS_RT) + overlay.bindButton(R.id.buttonStart, ControllerButtons.BUTTON_START) + overlay.bindButton(R.id.buttonBack, ControllerButtons.BUTTON_BACK) + + // The C buttons sit on the right stick, which is how the N64 pad is + // mapped, so the d-pad cluster drives that axis rather than the d-pad. + overlay.bindAxisButton(R.id.buttonDpadUp, ControllerButtons.AXIS_RY, positive = false) + overlay.bindAxisButton(R.id.buttonDpadDown, ControllerButtons.AXIS_RY, positive = true) + overlay.bindAxisButton(R.id.buttonDpadLeft, ControllerButtons.AXIS_RX, positive = false) + overlay.bindAxisButton(R.id.buttonDpadRight, ControllerButtons.AXIS_RX, positive = true) + + modsButton = overlay.findViewById(R.id.buttonMods) + modsButton.setOnClickListener { startActivity(Intent(this, ModsActivity::class.java)) } + + setupMenuButton(overlay.findViewById(R.id.buttonMenu)) + setupJoystick(overlay.findViewById(R.id.left_joystick), overlay.findViewById(R.id.left_joystick_knob)) + setupLookArea(overlay.findViewById(R.id.right_screen_area)) + setupToggleButton(overlay.findViewById(R.id.buttonToggle), buttonGroup) + } + + @SuppressLint("ClickableViewAccessibility") + private fun View.bindButton(id: Int, button: Int) { + val view = findViewById