diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1f240266..00000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -*.zip filter=lfs diff=lfs merge=lfs -text -*.so filter=lfs diff=lfs merge=lfs -text -*.tar.xz filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 3e84fdd4..a2c8fc52 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -1,338 +1,108 @@ -name: Build RAL APK (Debug & Safe) +name: RotatingartLauncher 🚀 on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] workflow_dispatch: - inputs: - build_debug: - description: '构建开发者版本 (Debug)' - type: boolean - default: true - build_safe: - description: '构建用户版本 Safe (推荐✅)' - type: boolean - default: true - -env: - ANDROID_COMPILE_SDK: "36" - ANDROID_BUILD_TOOLS: "36.0.0" - ANDROID_NDK_VERSION: "28.0.13004108" - ANDROID_CMAKE_VERSION: "3.22.1" jobs: build: runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read - actions: write - - # 并发控制:防止同时运行多个构建 - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false steps: - # ==================== 初始化与缓存 ==================== - - name: Check Disk Space Before - run: | - echo "📊 初始磁盘空间:" - df -h / - - - uses: actions/checkout@v4 - with: - submodules: recursive - lfs: true - # 浅克隆节省时间和空间 - fetch-depth: 1 - - # 多级缓存策略:Gradle Wrapper - - name: Cache Gradle Wrapper - uses: actions/cache@v4 - with: - path: | - ~/.gradle/wrapper - ~/.gradle/caches/wrapper - key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle-wrapper- - - # 多级缓存策略:Gradle 依赖(严格匹配 build.gradle) - - name: Cache Gradle Dependencies - uses: actions/cache@v4 + - name: 🔎 Checkout Source Code + uses: actions/checkout@v4 with: - path: | - ~/.gradle/caches/modules-2 - ~/.gradle/caches/build-cache-1 - ~/.gradle/caches/jars-3 - key: ${{ runner.os }}-gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle.properties') }} - restore-keys: | - ${{ runner.os }}-gradle-deps- - - # 多级缓存策略:Android SDK 组件 - - name: Cache Android SDK - uses: actions/cache@v4 - with: - path: | - /usr/local/lib/android/sdk/build-tools/${{ env.ANDROID_BUILD_TOOLS }} - /usr/local/lib/android/sdk/platforms/android-${{ env.ANDROID_COMPILE_SDK }} - /usr/local/lib/android/sdk/ndk/${{ env.ANDROID_NDK_VERSION }} - key: ${{ runner.os }}-android-sdk-${{ env.ANDROID_COMPILE_SDK }}-${{ env.ANDROID_BUILD_TOOLS }}-${{ env.ANDROID_NDK_VERSION }} - restore-keys: | - ${{ runner.os }}-android-sdk- - - # ==================== 环境设置 ==================== - - name: Setup JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' # 与上面的缓存互补 + lfs: false + submodules: false - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - name: Install SDK Components - id: install_sdk + - name: 🚫 Disable Global Git LFS run: | - echo "::group::安装 SDK 组件" - sdkmanager --install \ - "platforms;android-${ANDROID_COMPILE_SDK}" \ - "build-tools;${ANDROID_BUILD_TOOLS}" \ - "ndk;${ANDROID_NDK_VERSION}" \ - "cmake;${ANDROID_CMAKE_VERSION}" \ - 2>&1 | tee sdk-install.log - echo "::endgroup::" - - yes | sdkmanager --licenses || true - echo "installed=true" >> $GITHUB_OUTPUT + git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f" + git config --global filter.lfs.process "git-lfs filter-process --skip" - - name: Make gradlew executable - run: chmod +x ./gradlew - - # ==================== Debug 版本构建 ==================== - - name: Build Developer APK (Debug) - id: build_debug - if: github.event.inputs.build_debug == 'true' + - name: 🔁 Force Update Broken Submodules run: | - echo "::group::构建 Debug 版本" - ./gradlew clean assembleDebug --no-daemon --stacktrace 2>&1 | tee debug-build.log - echo "::endgroup::" - continue-on-error: true + git submodule update --remote --init --recursive || true - - name: Debug Build Error Report - if: steps.build_debug.outcome == 'failure' - run: | - echo "::error::Debug 构建失败!查看日志: debug-build.log" - tail -100 debug-build.log || true - # 上传错误日志 - exit 1 - - - name: Prepare Debug APK - id: prepare_debug - if: github.event.inputs.build_debug == 'true' && steps.build_debug.outcome == 'success' - run: | - DEBUG_DIR="app/build/outputs/apk/debug" - - if [ ! -d "$DEBUG_DIR" ]; then - echo "::error::Debug 输出目录不存在" - exit 1 - fi - - FIRST_APK=$(find "$DEBUG_DIR" -name "*.apk" -type f | head -1) - - if [ -z "$FIRST_APK" ]; then - echo "::error::未找到 Debug APK 文件" - ls -la "$DEBUG_DIR" || true - exit 1 - fi - - # 清理多余 APK - find "$DEBUG_DIR" -name "*.apk" ! -path "$FIRST_APK" -type f -delete - mv "$FIRST_APK" "$DEBUG_DIR/app-debug.apk" - - echo "file=$DEBUG_DIR/app-debug.apk" >> $GITHUB_OUTPUT - echo "size=$(du -h "$DEBUG_DIR/app-debug.apk" | cut -f1)" >> $GITHUB_OUTPUT - echo "✅ Debug APK 准备完成: $(ls -lh "$DEBUG_DIR/app-debug.apk")" - - - name: Upload Debug APK - if: github.event.inputs.build_debug == 'true' && steps.prepare_debug.outcome == 'success' - uses: actions/upload-artifact@v4 + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v4 with: - name: 1-RAL-Developer-Debug - path: ${{ steps.prepare_debug.outputs.file }} - retention-days: 30 - if-no-files-found: error - compression-level: 6 + java-version: '17' + distribution: 'temurin' + cache: gradle - # ==================== Safe Release 版本构建 ==================== - - name: Generate Signing Key - id: gen_key - if: github.event.inputs.build_safe == 'true' + - name: 🧪 Inject Heavy LFS Payloads run: | - keytool -genkey -v \ - -keystore debug.keystore \ - -alias androiddebugkey \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 \ - -storepass android \ - -keypass android \ - -dname "CN=Android Debug, O=Android, C=US" 2>&1 | tee keygen.log + mkdir -p app/src/main/assets/ + cd app/src/main/assets/ + rm -f MonoMod.zip runtime_libs.tar.xz dotnet.tar.xz - if [ ! -f "debug.keystore" ]; then - echo "::error::密钥生成失败" - exit 1 - fi - - echo "keystore_generated=true" >> $GITHUB_OUTPUT + wget -q -O MonoMod.zip "https://github.com/WinterWolfVN/RotatingartLauncher/raw/refs/heads/main/app/src/main/assets/MonoMod.zip" + wget -q -O runtime_libs.tar.xz "https://github.com/WinterWolfVN/RotatingartLauncher/raw/refs/heads/main/app/src/main/assets/runtime_libs.tar.xz" + wget -q -O dotnet.tar.xz "https://github.com/WinterWolfVN/RotatingartLauncher/raw/refs/heads/main/app/src/main/assets/dotnet.tar.xz" - - name: Build Safe Release APK - id: build_safe - if: github.event.inputs.build_safe == 'true' && steps.gen_key.outcome == 'success' + - name: 💉 Inject C++ Source (For Local CMake Build) run: | - echo "::group::构建 Safe Release 版本" - ./gradlew assembleRelease --no-daemon --stacktrace 2>&1 | tee release-build.log - echo "::endgroup::" - continue-on-error: true + wget -q -O src.zip "https://github.com/WinterWolfVN/RotatingartLauncher/archive/refs/heads/main.zip" + unzip -q src.zip + cp -arf RotatingartLauncher-main/app/src/main/cpp/* app/src/main/cpp/ + rm -rf RotatingartLauncher-main src.zip - - name: Safe Build Error Report - if: steps.build_safe.outcome == 'failure' + - name: 🧬 The Great JNI Heist (Steal Pre-compiled .so files) run: | - echo "::error::Safe Release 构建失败!" - tail -100 release-build.log || true - exit 1 - - - name: Sign Safe APK - id: sign_safe - if: github.event.inputs.build_safe == 'true' && steps.build_safe.outcome == 'success' + mkdir -p app/src/main/jniLibs/arm64-v8a/ + cd app/src/main/jniLibs/arm64-v8a/ + rm -f *.so + + SO_FILES=( + "libEGL_angle.so" + "libGLESv2_angle.so" + "libOSMBridge.so" + "libOSMesa.so" + "libSkiaSharp.so" + "libc++_shared.so" + "libeasytier_android_jni.so" + "libfmod.so" + "libfmodL.so" + "libfmodstudio.so" + "libfmodstudioL.so" + "libhardware.so" + "liblua54.so" + "liblwjgl_lz4.so" + "libmobileglues.so" + "libopenal32.so" + "libspirv-cross-c-shared.so" + "libsync.so" + "libtheorafile.so" + "libvulkan_freedreno.so" + ) + + for FILE in "${SO_FILES[@]}"; do + wget -q -O "$FILE" "https://github.com/WinterWolfVN/RotatingartLauncher/raw/refs/heads/main/app/src/main/jniLibs/arm64-v8a/$FILE" + done + + - name: 🗝️ Forge Dummy Keystore run: | - RELEASE_DIR="app/build/outputs/apk/release" - UNSIGNED=$(find "$RELEASE_DIR" -name "*-unsigned.apk" | head -1) - - if [ -z "$UNSIGNED" ] || [ ! -f "$UNSIGNED" ]; then - echo "::error::未找到未签名 APK" - ls -la "$RELEASE_DIR" || true - exit 1 - fi - - echo "签名文件: $UNSIGNED" - - ${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}/apksigner sign \ - --ks debug.keystore \ - --ks-key-alias androiddebugkey \ - --ks-pass pass:android \ - --key-pass pass:android \ - --out "$RELEASE_DIR/app-release-safe.apk" \ - "$UNSIGNED" 2>&1 | tee sign.log - - if [ ! -f "$RELEASE_DIR/app-release-safe.apk" ]; then - echo "::error::签名失败,未生成签名 APK" - exit 1 - fi - - # 验证签名 - ${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}/apksigner verify -v "$RELEASE_DIR/app-release-safe.apk" | head -5 - - rm -f "$UNSIGNED" "$UNSIGNED.idsig" debug.keystore - - echo "file=$RELEASE_DIR/app-release-safe.apk" >> $GITHUB_OUTPUT - echo "size=$(du -h "$RELEASE_DIR/app-release-safe.apk" | cut -f1)" >> $GITHUB_OUTPUT - echo "✅ Safe APK 签名完成" + keytool -genkeypair -v -keystore app/dummy.keystore -alias ralaunch -keyalg RSA -keysize 2048 -validity 10000 -storepass 123456 -keypass 123456 -dname "CN=Hacker, OU=Dev, O=RaLaunch, C=VN" - - name: Upload Safe APK - if: github.event.inputs.build_safe == 'true' && steps.sign_safe.outcome == 'success' - uses: actions/upload-artifact@v4 - with: - name: 2-RAL-User-Safe-RECOMMENDED - path: ${{ steps.sign_safe.outputs.file }} - retention-days: 30 - if-no-files-found: error - compression-level: 6 - - # ==================== 构建后清理与通知 ==================== - - name: Cleanup Build Artifacts - if: always() + - name: ⚒️ Build Release APK (With CMake Active) run: | - echo "::group::清理临时文件" - # 清理 Gradle Daemon(如果在运行) - ./gradlew --stop 2>/dev/null || true - - # 清理构建产物(保留已上传的 artifact) - rm -rf app/build/outputs/apk/*/temp_* 2>/dev/null || true - rm -rf app/build/intermediates 2>/dev/null || true - rm -f *.log debug.keystore 2>/dev/null || true - - # 清理 SDK 安装日志 - rm -f sdk-install.log keygen.log sign.log 2>/dev/null || true - - echo "✅ 临时文件已清理" + chmod +x gradlew + ./gradlew clean assembleRelease --no-daemon - - name: System Cleanup - if: always() + - name: 🛡️ Optimize and Sign APK run: | - echo "::group::系统级清理" - # 清理 apt 缓存 - sudo apt-get clean || true - sudo rm -rf /var/lib/apt/lists/* 2>/dev/null || true - - # 清理 Docker(如果使用) - docker system prune -f 2>/dev/null || true + APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1) + BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/34.0.0" - echo "📊 最终磁盘空间:" - df -h / - echo "::endgroup::" + $BUILD_TOOLS_PATH/zipalign -v -p 4 $APK_PATH app/build/outputs/apk/release/aligned.apk + $BUILD_TOOLS_PATH/apksigner sign --ks app/dummy.keystore --ks-pass pass:123456 --ks-key-alias ralaunch --key-pass pass:123456 --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --out app/build/outputs/apk/release/RAL_release.apk app/build/outputs/apk/release/aligned.apk - - name: Error Log Upload (On Failure) - if: failure() + - name: 🎉 Upload Final Artifact uses: actions/upload-artifact@v4 with: - name: build-error-logs-${{ github.run_id }} - path: | - *.log - app/build/outputs/**/*.log - retention-days: 7 - if-no-files-found: ignore - - - name: Build Summary & Notification - if: always() - run: | - echo "==========================================" - echo "📊 构建报告" - echo "==========================================" - echo "⏱️ 耗时: $(($SECONDS / 60)) 分 $(($SECONDS % 60)) 秒" - echo "" - - # Debug 状态 - if [ "${{ github.event.inputs.build_debug }}" == "true" ]; then - if [ "${{ steps.build_debug.outcome }}" == "success" ] && [ "${{ steps.prepare_debug.outcome }}" == "success" ]; then - echo "✅ Debug 版本: 成功 (${{ steps.prepare_debug.outputs.size }})" - else - echo "❌ Debug 版本: 失败" - fi - fi - - # Safe 状态 - if [ "${{ github.event.inputs.build_safe }}" == "true" ]; then - if [ "${{ steps.build_safe.outcome }}" == "success" ] && [ "${{ steps.sign_safe.outcome }}" == "success" ]; then - echo "✅ Safe 版本: 成功 (${{ steps.sign_safe.outputs.size }}) ⭐ 推荐" - else - echo "❌ Safe 版本: 失败" - fi - fi - - echo "" - echo "💡 使用建议:" - echo " • 日常用户 → 下载 2-RAL-User-Safe-RECOMMENDED" - echo " 已启用代码优化,无资源压缩风险" - echo "" - echo " • 开发者 → 下载 1-RAL-Developer-Debug" - echo " 无优化,可调试,适合排查问题" - echo "" - - if [ "${{ job.status }}" == "failure" ]; then - echo "⚠️ 构建遇到错误,已上传日志: build-error-logs-${{ github.run_id }}" - fi - - echo "📥 下载地址:" - echo " ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - echo "==========================================" + name: RAL_release + path: app/build/outputs/apk/release/RAL_release.apk diff --git a/.github/workflows/sync-fork.yml b/.github/workflows/sync-fork.yml new file mode 100644 index 00000000..1edc4d48 --- /dev/null +++ b/.github/workflows/sync-fork.yml @@ -0,0 +1,59 @@ +name: Sync Fork (Keep My Code) + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync: + name: Sync with Upstream (Keep My Changes) + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PAT_TOKEN }} + + - name: Get upstream repo URL + id: upstream + env: + GH_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + UPSTREAM=$(gh api repos/${{ github.repository }} --jq '.parent.full_name') + if [ -z "$UPSTREAM" ] || [ "$UPSTREAM" = "null" ]; then + echo "This repo is not a fork!" + exit 1 + fi + echo "repo=$UPSTREAM" >> $GITHUB_OUTPUT + echo "Upstream found: $UPSTREAM" + + - name: Config Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Merge upstream keep my code on conflict + id: merge + run: | + git remote add upstream https://github.com/${{ steps.upstream.outputs.repo }}.git + git fetch upstream main + BEFORE=$(git rev-parse HEAD) + git merge upstream/main --strategy-option ours --allow-unrelated-histories --no-edit || echo "Nothing to merge" + AFTER=$(git rev-parse HEAD) + if [ "$BEFORE" != "$AFTER" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "Code changed, will push and trigger build" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "No changes" + fi + + - name: Push + if: steps.merge.outputs.changed == 'true' + run: git push origin main diff --git a/README.md b/README.md index ad19bffb..c3a62fcd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

-[![Android](https://img.shields.io/badge/Android_9.0+-34A853?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com) +[![Android](https://img.shields.io/badge/Android_7.0+-34A853?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com) [![.NET](https://img.shields.io/badge/.NET_10.0-512BD4?style=for-the-badge&logo=dotnet&logoColor=white)](https://dotnet.microsoft.com) [![Kotlin](https://img.shields.io/badge/Kotlin_2.0-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) @@ -25,6 +25,8 @@ **Rotating Art Launcher** is an Android application that lets you run .NET-based desktop games on mobile devices.
Supports FNA/XNA framework games and mod loaders like tModLoader, SMAPI, and Everest. +⚠️Warning⚠️: This is a modified version for Android 7.0+ + --- @@ -115,7 +117,7 @@ Supports FNA/XNA framework games and mod loaders like tModLoader, SMAPI, and Eve ### Requirements -> - 📱 Android 9.0 (API 28) or higher +> - 📱 Android 7.0 (API 24) or higher > - 🏗️ ARM64-v8a architecture device > - 💾 At least 2GB free storage diff --git a/README_ZH.md b/README_ZH.md index 75931744..64f03c2a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -10,7 +10,7 @@

-[![Android](https://img.shields.io/badge/Android_9.0+-34A853?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com) +[![Android](https://img.shields.io/badge/Android_7.0+-34A853?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com) [![.NET](https://img.shields.io/badge/.NET_10.0-512BD4?style=for-the-badge&logo=dotnet&logoColor=white)](https://dotnet.microsoft.com) [![Kotlin](https://img.shields.io/badge/Kotlin_2.0-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) @@ -25,6 +25,8 @@ **Rotating Art Launcher** 是一款 Android 应用,让你在移动设备上运行基于 .NET 的桌面游戏。
支持 FNA/XNA 框架游戏、tModLoader、SMAPI、Everest 等模组加载器。 +⚠️警告⚠️:这是针对Android 7.0+的修改版本 + --- @@ -115,7 +117,7 @@ ### 系统要求 -> - 📱 Android 9.0 (API 28) 或更高 +> - 📱 Android 7.0 (API 24) 或更高 > - 🏗️ ARM64-v8a 架构设备 > - 💾 至少 2GB 可用存储 diff --git a/app/build.gradle b/app/build.gradle index 0214c106..fdfe486a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,62 +12,75 @@ android { defaultConfig { applicationId "com.app.ralaunch" - minSdk 28 + + // ... Support for Android 7... + minSdk 24 targetSdk 35 versionCode 3 versionName "2.0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - // 只编译 arm64-v8a 架构 - // 使用 splits 配置 - + + // ... Enable multidex to prevent method limit issues on older devices ... + multiDexEnabled true + externalNativeBuild { cmake { - + arguments "-DANDROID_PLATFORM=android-24" } } } + signingConfigs { + release { + storeFile file("dummy.keystore") + storePassword "123456" + keyAlias "ralaunch" + keyPassword "123456" + + enableV1Signing true + enableV2Signing true + enableV3Signing true + } + } buildTypes { - debug { - debuggable true - minifyEnabled false - jniDebuggable true // 启用 Native 调试 - - - } release { debuggable false - minifyEnabled false // TODO: 修复类重复后启用 R8 + minifyEnabled false shrinkResources false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - // Release 移除调试符号,减小 native 库体积 + // ... Apply the dummy signing config ... + signingConfig signingConfigs.release + externalNativeBuild { cmake { - arguments "-DCMAKE_BUILD_TYPE=Release" + arguments "-DCMAKE_BUILD_TYPE=Release", "-DANDROID_PLATFORM=android-24" } } } } + compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + // ... Keep desugaring enabled for other modern Java APIs safely ... + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } + externalNativeBuild { cmake { path file('src/main/cpp/CMakeLists.txt') version '3.22.1' } } + buildFeatures { viewBinding true prefab true compose true } - // 禁止 AAPT 压缩这些文件类型,确保 AssetManager 能正确读取 androidResources { noCompress += ['tar.gz', 'tar.xz', 'xz'] } @@ -75,13 +88,10 @@ android { packagingOptions { jniLibs { useLegacyPackaging = true - // Debug 版本保留符号便于调试,Release 版本移除符号减小体积 - // keepDebugSymbols += ['**/*.so'] // 仅 debug 时启用 - pickFirsts += ['lib/arm64-v8a/libc++_shared.so', 'lib/arm64-v8a/libdotnethost.so'] + pickFirsts += ['**/*.so'] } } - // 临时只针对真机arm64-v8a编译 splits { abi { enable true @@ -91,7 +101,6 @@ android { } } - // 使用预编译的 System.Security.Cryptography jar,实现官方托管绑定 sourceSets { main { java { @@ -101,49 +110,51 @@ android { } kotlinOptions { - jvmTarget = '21' + jvmTarget = '17' } - // 关闭 Lint 检查 lint { abortOnError false checkReleaseBuilds false } } +// ... DEPENDENCIES BLOCK (Outside and at the bottom) ... dependencies { - // 本地 JAR/AAR 依赖 + // ... Desugaring library ... + coreLibraryDesugaring libs.desugar.jdk.libs + + // ... Multidex support ... + implementation 'androidx.multidex:multidex:2.0.1' + + // ... Local libraries ... implementation files('../app/libs/libSystem.Security.Cryptography.Native.Android.jar') implementation files('../external/libs/fmod.jar') implementation files('libs/fishnet-release.aar') - // 项目模块 implementation project(':shared') - // 从 ralib 迁移的依赖 + // ... 3rd party libraries ... implementation 'com.github.omicronapps:7-Zip-JBinding-4Android:Release-16.02-2.03' implementation 'com.google.code.gson:gson:2.13.2' - // AndroidX 核心库 implementation libs.core.ktx implementation libs.appcompat implementation libs.activity implementation libs.constraintlayout implementation libs.recyclerview - - // Material Design implementation libs.material - // Kotlin 扩展 + // ... Coroutines & Serialization ... implementation libs.kotlinx.coroutines.core implementation libs.kotlinx.coroutines.android implementation libs.kotlinx.serialization.json - // Haze (Glassmorphism blur) + // ... UI effects ... implementation libs.haze implementation libs.haze.materials - // Compose + // ... Jetpack Compose ... implementation platform(libs.compose.bom) implementation libs.compose.ui implementation libs.compose.ui.graphics @@ -157,20 +168,21 @@ dependencies { implementation 'com.github.jeziellago:compose-markdown:0.5.8' debugImplementation 'androidx.compose.ui:ui-tooling' - // Koin DI + // ... Koin DI ... implementation libs.koin.core implementation libs.koin.android implementation libs.koin.compose implementation libs.koin.compose.viewmodel - // 第三方工具库 + // ... Misc tools ... implementation libs.glide implementation libs.konfetti - implementation libs.commons.compress - implementation libs.xz + implementation 'org.tukaani:xz:1.9' // <--- KEEPT: Our custom ArchiveExtractor needs this to read .xz files! implementation libs.android.svg + + // ... NOTE: libs.commons.compress HAS BEEN COMPLETELY PURGED! - // 测试依赖 + // ... Testing ... testImplementation libs.junit androidTestImplementation libs.ext.junit androidTestImplementation libs.espresso.core diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d5343f3..10b64be7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,37 +1,34 @@ + xmlns:tools="http://schemas.android.com/tools"> - - + - - + - - - - + + + - - - - + + + + + + - + - - + + android:windowSoftInputMode="adjustPan" /> + - - + android:windowSoftInputMode="adjustPan"> - - - + android:theme="@style/Theme.raLaunch" /> - + android:theme="@style/Theme.raLaunch" /> - + android:foregroundServiceType="dataSync" + android:process=":launcher" + tools:targetApi="29" /> - - + android:permission="android.permission.BIND_VPN_SERVICE" + android:process=":game" + tools:targetApi="29"> - - - + diff --git a/app/src/main/assets/runtime_libs.tar.xz b/app/src/main/assets/runtime_libs.tar.xz new file mode 100644 index 00000000..1aeb0528 Binary files /dev/null and b/app/src/main/assets/runtime_libs.tar.xz differ diff --git a/app/src/main/cpp/liblinkernsbypass/android_linker_ns.cpp b/app/src/main/cpp/liblinkernsbypass/android_linker_ns.cpp index 4d3e0770..814165ad 100644 --- a/app/src/main/cpp/liblinkernsbypass/android_linker_ns.cpp +++ b/app/src/main/cpp/liblinkernsbypass/android_linker_ns.cpp @@ -19,6 +19,9 @@ extern "C" { #endif +#define LOG_TAG "linkernsbypass" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) using loader_android_create_namespace_t = android_namespace_t *(*)(const char *, const char *, const char *, uint64_t, @@ -28,6 +31,7 @@ using loader_android_create_namespace_t = android_namespace_t *(*)(const char *, static loader_android_create_namespace_t loader_android_create_namespace; static bool lib_loaded; +static int device_api_level = 0; // Cache API level /* Public API */ @@ -41,6 +45,12 @@ struct android_namespace_t *android_create_namespace(const char *name, uint64_t type, const char *permitted_when_isolated_path, android_namespace_t *parent_namespace) { + // === FIX: Android < 28 không có namespace, trả về NULL === + if (!lib_loaded) { + LOGW("android_create_namespace: not available on API %d, returning NULL", device_api_level); + return nullptr; + } + auto caller{__builtin_return_address(0)}; return loader_android_create_namespace(name, ld_library_path, default_library_path, type, permitted_when_isolated_path, parent_namespace, caller); @@ -52,6 +62,12 @@ struct android_namespace_t *android_create_namespace_escape(const char *name, uint64_t type, const char *permitted_when_isolated_path, android_namespace_t *parent_namespace) { + // === FIX: Android < 28 không có namespace, trả về NULL === + if (!lib_loaded) { + LOGW("android_create_namespace_escape: not available on API %d, returning NULL", device_api_level); + return nullptr; + } + auto caller{reinterpret_cast(&dlopen)}; return loader_android_create_namespace(name, ld_library_path, default_library_path, type, permitted_when_isolated_path, parent_namespace, caller); @@ -64,8 +80,13 @@ android_link_namespaces_all_libs_t android_link_namespaces_all_libs; android_link_namespaces_t android_link_namespaces; bool linkernsbypass_link_namespace_to_default_all_libs(android_namespace_t *to) { - // Creating a shared namespace with the default parent will give a copy of the default namespace that we can actually access - // This is needed since there is no way to access a direct handle to the default namespace as it's not exported + // === FIX: Android < 28 không có namespace restriction, trả về true (thành công) === + if (!lib_loaded) { + LOGI("linkernsbypass_link_namespace_to_default_all_libs: " + "API %d has no namespace restrictions, returning success", device_api_level); + return true; + } + static auto defaultNs{android_create_namespace_escape("default_copy", nullptr, nullptr, ANDROID_NAMESPACE_TYPE_SHARED, nullptr, nullptr)}; @@ -73,6 +94,13 @@ bool linkernsbypass_link_namespace_to_default_all_libs(android_namespace_t *to) } void *linkernsbypass_namespace_dlopen(const char *filename, int flags, android_namespace_t *ns) { + // === FIX: Android < 28 không có namespace, dùng dlopen thường === + if (!lib_loaded || ns == nullptr) { + LOGI("linkernsbypass_namespace_dlopen: falling back to dlopen() for '%s' (API %d)", + filename ? filename : "null", device_api_level); + return dlopen(filename, flags); + } + android_dlextinfo extInfo{ .flags = ANDROID_DLEXT_USE_NAMESPACE, .library_namespace = ns @@ -92,6 +120,13 @@ void *linkernsbypass_namespace_dlopen(const char *filename, int flags, android_n void * linkernsbypass_namespace_dlopen_unique(const char *libPath, const char *libTargetDir, int flags, android_namespace_t *ns) { + // === FIX: Android < 28 không có namespace, dùng dlopen thường === + if (!lib_loaded || ns == nullptr) { + LOGI("linkernsbypass_namespace_dlopen_unique: falling back to dlopen() for '%s' (API %d)", + libPath ? libPath : "null", device_api_level); + return dlopen(libPath, flags); + } + static std::array PathBuf{}; // Used as a unique ID for overwriting soname and creating target lib files @@ -142,8 +177,15 @@ static void *align_ptr(void *ptr) { __attribute__((constructor)) static void resolve_linker_symbols() { using loader_dlopen_t = void *(*)(const char *, int, const void *); - if (android_get_device_api_level() < 28) + // === FIX: Cache API level để dùng trong log === + device_api_level = android_get_device_api_level(); + + if (device_api_level < 28) { + LOGI("API level %d < 28: linker namespace bypass disabled, " + "using standard dlopen() fallback (no namespace restrictions on this Android version)", + device_api_level); return; + } // ARM64 specific function walking to locate the internal dlopen handler auto loader_dlopen{[]() { @@ -161,11 +203,11 @@ __attribute__((constructor)) static void resolve_linker_symbols() { }; static_assert(sizeof(BranchLinked) == 4, "BranchLinked is wrong size"); - // Some devices ship with --X mapping for exexecutables so work around that + // Some devices ship with --X mapping for executables so work around that mprotect(align_ptr(reinterpret_cast(&dlopen)), getpagesize(), PROT_WRITE | PROT_READ | PROT_EXEC); - // dlopen is just a wrapper for __loader_dlopen that passes the return address as the third arg hence we can just walk it to find __loader_dlopen + // dlopen is just a wrapper for __loader_dlopen that passes the return address as the third arg auto blInstr{reinterpret_cast(&dlopen)}; while (!blInstr->Verify()) blInstr++; @@ -173,11 +215,9 @@ __attribute__((constructor)) static void resolve_linker_symbols() { return reinterpret_cast(blInstr + blInstr->offset); }()}; - // Protect the loader_dlopen function to remove the BTI attribute (since this is an internal function that isn't intended to be jumped indirectly to) mprotect(align_ptr(reinterpret_cast(&loader_dlopen)), getpagesize(), PROT_WRITE | PROT_READ | PROT_EXEC); - // Passing dlopen as a caller address tricks the linker into using the internal unrestricted namespace letting us access libraries that are normally forbidden in the classloader namespace imposed on apps auto ldHandle{loader_dlopen("ld-android.so", RTLD_LAZY, reinterpret_cast(&dlopen))}; if (!ldHandle) return; @@ -209,6 +249,7 @@ __attribute__((constructor)) static void resolve_linker_symbols() { // Lib is now safe to use lib_loaded = true; + LOGI("Linker namespace bypass loaded successfully on API %d", device_api_level); } #ifdef __cplusplus diff --git a/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt b/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt index ae5d8471..5496f0b2 100644 --- a/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt +++ b/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt @@ -3,6 +3,7 @@ package com.app.ralaunch import android.app.Application import android.content.Context import android.content.res.Configuration +import android.os.Build import android.system.Os import android.util.Log import androidx.appcompat.app.AppCompatDelegate @@ -18,12 +19,8 @@ import com.kyant.fishnet.Fishnet import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent import java.io.File +import com.app.ralaunch.core.platform.runtime.BlackBoxLogger -/** - * 应用程序 Application 类 (Kotlin 重构版) - * - * 使用 Koin DI 框架管理依赖 - */ class RaLaunchApp : Application(), KoinComponent { companion object { @@ -32,21 +29,14 @@ class RaLaunchApp : Application(), KoinComponent { @Volatile private var instance: RaLaunchApp? = null - /** - * 获取全局 Application 实例 - */ @JvmStatic fun getInstance(): RaLaunchApp = instance ?: throw IllegalStateException("Application not initialized") - /** - * 获取全局 Context(兼容旧代码) - */ @JvmStatic fun getAppContext(): Context = getInstance().applicationContext } - // 延迟注入(在 Koin 初始化后才能使用) private val _vibrationManager: VibrationManager by inject() private val _controlPackManager: ControlPackManager by inject() private val _patchManager: PatchManager? by inject() @@ -55,23 +45,37 @@ class RaLaunchApp : Application(), KoinComponent { super.onCreate() instance = this - // 1. 初始化密度适配(必须最先) - DensityAdapter.init(this) + BlackBoxLogger.startRecording(this) - // 2. 初始化 Koin DI(必须在使用 inject 之前) - KoinInitializer.init(this) + val startupLogFile = File(filesDir, "startup_log.txt") + startupLogFile.delete() - // 3. 应用主题设置 - applyThemeFromSettings() + fun writeLog(msg: String) { + Log.i(TAG, msg) + try { startupLogFile.appendText("$msg\n") } catch (e: Exception) { } + } - // 4. 初始化崩溃捕获 - initCrashHandler() + fun step(name: String, block: () -> Unit) { + writeLog("▶ $name...") + try { + block() + writeLog("✅ $name OK") + } catch (e: Throwable) { + writeLog("❌ $name FAILED: ${e.javaClass.name} - ${e.message}") + Log.e(TAG, "Init step failed: $name", e) + } + } - // 5. 后台安装补丁 - installPatchesInBackground() + writeLog("=== App Start: Android ${Build.VERSION.SDK_INT} ===") + + step("DensityAdapter") { DensityAdapter.init(this) } + step("KoinInitializer") { KoinInitializer.init(this) } + step("Theme") { applyThemeFromSettings() } + step("Fishnet") { initCrashHandler() } + step("Patches") { installPatchesInBackground() } + step("EnvVars") { setupEnvironmentVariables() } - // 6. 设置环境变量 - setupEnvironmentVariables() + writeLog("=== Init Complete ===") } override fun attachBaseContext(base: Context) { @@ -85,32 +89,29 @@ class RaLaunchApp : Application(), KoinComponent { private fun applyThemeFromSettings() { try { - val settingsManager = SettingsAccess - val nightMode = when (settingsManager.themeMode) { - ThemeMode.FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES - ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + val nightMode = when (SettingsAccess.themeMode) { + 0 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + 1 -> AppCompatDelegate.MODE_NIGHT_YES + 2 -> AppCompatDelegate.MODE_NIGHT_NO + else -> AppCompatDelegate.MODE_NIGHT_NO } AppCompatDelegate.setDefaultNightMode(nightMode) } catch (e: Exception) { Log.e(TAG, "Failed to apply theme: ${e.message}") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } } - /** - * 初始化崩溃捕获 - */ private fun initCrashHandler() { - val logDir = File(filesDir, "crash_logs").apply { - if (!exists()) mkdirs() + try { + val logDir = File(filesDir, "crash_logs").apply { + if (!exists()) mkdirs() + } + Fishnet.init(applicationContext, logDir.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Fishnet init failed: ${e.message}") } - Fishnet.init(applicationContext, logDir.absolutePath) } - /** - * 后台安装补丁 - */ private fun installPatchesInBackground() { _patchManager?.let { manager -> Thread({ @@ -124,37 +125,19 @@ class RaLaunchApp : Application(), KoinComponent { } } - /** - * 设置环境变量 - */ private fun setupEnvironmentVariables() { try { Os.setenv("PACKAGE_NAME", packageName, true) - val externalStorage = android.os.Environment.getExternalStorageDirectory() externalStorage?.let { Os.setenv("EXTERNAL_STORAGE_DIRECTORY", it.absolutePath, true) - Log.d(TAG, "EXTERNAL_STORAGE_DIRECTORY: ${it.absolutePath}") } } catch (e: Exception) { Log.e(TAG, "Failed to set environment variables: ${e.message}") } } - // ==================== 兼容旧代码的访问方法 ==================== - - /** - * 获取 VibrationManager - */ fun getVibrationManager(): VibrationManager = _vibrationManager - - /** - * 获取 ControlPackManager - */ fun getControlPackManager(): ControlPackManager = _controlPackManager - - /** - * 获取 PatchManager - */ fun getPatchManager(): PatchManager? = _patchManager } diff --git a/app/src/main/java/com/app/ralaunch/core/common/GameDeletionManager.kt b/app/src/main/java/com/app/ralaunch/core/common/GameDeletionManager.kt index 0d8978c2..b4c8526a 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/GameDeletionManager.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/GameDeletionManager.kt @@ -6,13 +6,7 @@ import com.app.ralaunch.shared.core.platform.AppConstants import com.app.ralaunch.core.common.util.AppLogger import com.app.ralaunch.core.common.util.FileUtils import java.io.File -import java.nio.file.Paths -/** - * 游戏删除管理器 - * - * 使用新的存储结构: games/{GameDirName}/game_info.json - */ class GameDeletionManager(private val context: Context) { fun deleteGameFiles(game: GameItem): Boolean { @@ -24,9 +18,10 @@ class GameDeletionManager(private val context: Context) { return false } - FileUtils.deleteDirectoryRecursively(Paths.get(gameDir.absolutePath)) + // Thay Paths.get() bang File + FileUtils.deleteDirectoryRecursively(gameDir) } catch (e: Exception) { - AppLogger.error("GameDeletionManager", "删除游戏文件时发生错误: ${e.message}") + AppLogger.error("GameDeletionManager", "Error deleting game files: ${e.message}") false } } @@ -34,17 +29,14 @@ class GameDeletionManager(private val context: Context) { fun deleteGame(path: String?): Boolean { if (path.isNullOrEmpty()) return false return try { - FileUtils.deleteDirectoryRecursively(Paths.get(path)) + // Thay Paths.get() bang File + FileUtils.deleteDirectoryRecursively(File(path)) } catch (e: Exception) { - AppLogger.error("GameDeletionManager", "删除游戏文件时发生错误: ${e.message}") + AppLogger.error("GameDeletionManager", "Error deleting game: ${e.message}") false } } - /** - * 获取游戏目录 - * 根据新的存储结构,目录名就是 storageBasePathRelative - */ private fun getGameDirectory(game: GameItem): File? { if (game.storageRootPathRelative.isBlank()) return null diff --git a/app/src/main/java/com/app/ralaunch/core/common/util/AppLogger.kt b/app/src/main/java/com/app/ralaunch/core/common/util/AppLogger.kt index 0f09f939..9a5935a6 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/util/AppLogger.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/util/AppLogger.kt @@ -3,38 +3,16 @@ package com.app.ralaunch.core.common.util import android.util.Log import com.app.ralaunch.core.common.SettingsAccess import com.app.ralaunch.shared.core.util.Logger -import com.app.ralaunch.shared.core.util.LogLevel import java.io.File -/** - * 统一日志系统 - * - 所有日志输出到 logcat - * - LogcatReader 捕获 logcat 并保存到文件 - * - * 实现 shared 模块的 Logger 接口 - */ object AppLogger : Logger { private const val TAG = "RALaunch" - private const val ENABLE_DEBUG = false + private val ENABLE_DEBUG = true private var logcatReader: LogcatReader? = null private var logDir: File? = null private var initialized = false - /** - * 日志级别 - 使用 shared 模块的 LogLevel - */ - @Deprecated("使用 com.app.ralaunch.shared.core.util.LogLevel", ReplaceWith("LogLevel")) - enum class Level(val priority: Int, val tag: String) { - ERROR(0, "E"), - WARN(1, "W"), - INFO(2, "I"), - DEBUG(3, "D") - } - - /** - * 初始化日志器 - */ @JvmStatic @JvmOverloads fun init(logDirectory: File, clearExistingLogs: Boolean = false) { @@ -54,8 +32,7 @@ object AppLogger : Logger { } logcatReader = LogcatReader.getInstance() - val settingsManager = SettingsAccess - if (settingsManager.isLogSystemEnabled) { + if (SettingsAccess.isLogSystemEnabled) { logcatReader?.start(logDir) Log.i(TAG, "LogcatReader started") } else { @@ -64,16 +41,11 @@ object AppLogger : Logger { initialized = true Log.i(TAG, "AppLogger.init() completed") - info("Logger", "Log system initialized: ${logDir?.absolutePath}") - - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize logging", e) + } catch (t: Throwable) { + Log.e(TAG, "Failed to initialize logging", t) } } - /** - * 关闭日志器 - */ @JvmStatic fun close() { logcatReader?.stop() @@ -83,32 +55,31 @@ object AppLogger : Logger { } override fun error(tag: String, message: String) { - log(Level.ERROR, tag, message, null) + Log.e(tag, message) } override fun error(tag: String, message: String, throwable: Throwable?) { - log(Level.ERROR, tag, message, throwable) + Log.e(tag, message, throwable) } override fun warn(tag: String, message: String) { - log(Level.WARN, tag, message, null) + Log.w(tag, message) } override fun warn(tag: String, message: String, throwable: Throwable?) { - log(Level.WARN, tag, message, throwable) + Log.w(tag, message, throwable) } override fun info(tag: String, message: String) { - log(Level.INFO, tag, message, null) + Log.i(tag, message) } override fun debug(tag: String, message: String) { if (ENABLE_DEBUG) { - log(Level.DEBUG, tag, message, null) + Log.d(tag, message) } } - - // JvmStatic 版本供 Java 调用 + @JvmStatic fun e(tag: String, message: String) = error(tag, message) @JvmStatic fun e(tag: String, message: String, t: Throwable?) = error(tag, message, t) @JvmStatic fun w(tag: String, message: String) = warn(tag, message) @@ -116,33 +87,21 @@ object AppLogger : Logger { @JvmStatic fun i(tag: String, message: String) = info(tag, message) @JvmStatic fun d(tag: String, message: String) = debug(tag, message) - private fun log(level: Level, tag: String, message: String, throwable: Throwable?) { - when (level) { - Level.ERROR -> if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message) - Level.WARN -> Log.w(tag, message) - Level.INFO -> Log.i(tag, message) - Level.DEBUG -> if (ENABLE_DEBUG) Log.d(tag, message) - } - } - - /** - * 获取当前日志文件 - */ @JvmStatic fun getLogFile(): File? = logcatReader?.logFile - /** - * 获取 LogcatReader 实例 - */ @JvmStatic fun getLogcatReader(): LogcatReader? = logcatReader private fun clearLogFiles(directory: File?) { - directory - ?.listFiles { file -> file.isFile && file.extension.equals("log", ignoreCase = true) } - ?.forEach { file -> - runCatching { file.delete() } - .onFailure { Log.w(TAG, "Failed to delete old log file: ${file.absolutePath}", it) } + try { + directory?.listFiles { file -> + file.isFile && file.extension.equals("log", ignoreCase = true) + }?.forEach { file -> + file.delete() } + } catch (t: Throwable) { + Log.w(TAG, "Failed to clear old logs", t) + } } } diff --git a/app/src/main/java/com/app/ralaunch/core/common/util/ArchiveExtractor.kt b/app/src/main/java/com/app/ralaunch/core/common/util/ArchiveExtractor.kt index 38206256..409659ff 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/util/ArchiveExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/util/ArchiveExtractor.kt @@ -1,19 +1,13 @@ package com.app.ralaunch.core.common.util import android.content.Context -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream -import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import org.tukaani.xz.XZInputStream import java.io.* -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.util.zip.GZIPInputStream -/** - * 通用归档文件解压工具 - */ object ArchiveExtractor { private const val TAG = "ArchiveExtractor" - private const val BUFFER_SIZE = 8192 + private const val BUFFER_SIZE = 65536 fun interface ProgressCallback { fun onProgress(processedFiles: Int, currentFile: String) @@ -22,12 +16,10 @@ object ArchiveExtractor { @JvmStatic @JvmOverloads fun extractTarGz(archiveFile: File, targetDir: File, stripPrefix: String?, callback: ProgressCallback? = null): Int { - FileInputStream(archiveFile).use { fis -> - BufferedInputStream(fis).use { bis -> - GZIPInputStream(bis).use { gzipIn -> - TarArchiveInputStream(gzipIn).use { tarIn -> - return extractTarEntries(tarIn, targetDir, stripPrefix, callback) - } + return FileInputStream(archiveFile).use { fis -> + BufferedInputStream(fis, BUFFER_SIZE).use { bis -> + GZIPInputStream(bis, BUFFER_SIZE).use { gzipIn -> + extractTarEntries(gzipIn, targetDir, stripPrefix, callback) } } } @@ -36,12 +28,10 @@ object ArchiveExtractor { @JvmStatic @JvmOverloads fun extractTarXz(archiveFile: File, targetDir: File, stripPrefix: String?, callback: ProgressCallback? = null): Int { - FileInputStream(archiveFile).use { fis -> - BufferedInputStream(fis).use { bis -> - XZCompressorInputStream(bis).use { xzIn -> - TarArchiveInputStream(xzIn).use { tarIn -> - return extractTarEntries(tarIn, targetDir, stripPrefix, callback) - } + return FileInputStream(archiveFile).use { fis -> + BufferedInputStream(fis, BUFFER_SIZE).use { bis -> + XZInputStream(bis).use { xzIn -> + extractTarEntries(xzIn, targetDir, stripPrefix, callback) } } } @@ -50,38 +40,42 @@ object ArchiveExtractor { @JvmStatic @JvmOverloads fun extractTar(archiveFile: File, targetDir: File, stripPrefix: String?, callback: ProgressCallback? = null): Int { - FileInputStream(archiveFile).use { fis -> - BufferedInputStream(fis).use { bis -> - TarArchiveInputStream(bis).use { tarIn -> - return extractTarEntries(tarIn, targetDir, stripPrefix, callback) - } + return FileInputStream(archiveFile).use { fis -> + BufferedInputStream(fis, BUFFER_SIZE).use { bis -> + extractTarEntries(bis, targetDir, stripPrefix, callback) } } } private fun extractTarEntries( - tarIn: TarArchiveInputStream, targetDir: File, + inStream: InputStream, targetDir: File, stripPrefix: String?, callback: ProgressCallback? ): Int { var processedFiles = 0 + var lastCallbackTime = 0L + val tarReader = MiniTarReader(inStream) - generateSequence { tarIn.nextTarEntry }.forEach { entry -> - if (!tarIn.canReadEntryData(entry)) return@forEach - - val entryName = normalizeEntryName(entry.name, stripPrefix) ?: return@forEach + while (true) { + val entry = tarReader.nextEntry() ?: break + val entryName = normalizeEntryName(entry.name, stripPrefix) ?: continue val targetFile = File(targetDir, entryName) - if (!isPathSafe(targetDir, targetFile)) return@forEach + if (!isPathSafe(targetDir, targetFile)) continue when { entry.isDirectory -> extractDirectory(targetFile) entry.isSymbolicLink -> extractSymlink(targetFile, entry.linkName) - else -> extractFile(tarIn, targetFile, entry.mode) + else -> extractFile(tarReader, targetFile, entry.mode) } processedFiles++ - if (callback != null && processedFiles % 10 == 0) { - callback.onProgress(processedFiles, entryName) + + if (callback != null) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastCallbackTime > 100) { + callback.onProgress(processedFiles, entryName) + lastCallbackTime = currentTime + } } } @@ -129,26 +123,28 @@ object ArchiveExtractor { try { android.system.Os.symlink(linkTarget, targetFile.absolutePath) } catch (e: Exception) { - AppLogger.warn(TAG, "Failed to create symlink: ${e.message}") targetFile.parentFile?.let { parent -> val linkTargetFile = File(parent, linkTarget) if (linkTargetFile.exists()) { try { - Files.copy(linkTargetFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING) - } catch (copyEx: Exception) { - AppLogger.warn(TAG, "Failed to copy symlink target: ${copyEx.message}") - } + linkTargetFile.copyTo(targetFile, overwrite = true) + } catch (copyEx: Exception) {} } } } } - private fun extractFile(tarIn: TarArchiveInputStream, targetFile: File, mode: Int) { + private fun extractFile(tarReader: MiniTarReader, targetFile: File, mode: Int) { targetFile.parentFile?.takeIf { !it.exists() }?.mkdirs() FileOutputStream(targetFile).use { fos -> - BufferedOutputStream(fos).use { bos -> - tarIn.copyTo(bos, BUFFER_SIZE) + BufferedOutputStream(fos, BUFFER_SIZE).use { bos -> + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + val read = tarReader.readData(buffer) + if (read <= 0) break + bos.write(buffer, 0, read) + } } } @@ -160,8 +156,107 @@ object ArchiveExtractor { fun copyAssetToFile(context: Context, assetFileName: String, targetFile: File) { context.assets.open(assetFileName).use { inputStream -> FileOutputStream(targetFile).use { fos -> - BufferedOutputStream(fos).use { bos -> - inputStream.copyTo(bos, BUFFER_SIZE) + BufferedOutputStream(fos, BUFFER_SIZE).use { bos -> + val buffer = ByteArray(BUFFER_SIZE) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + bos.write(buffer, 0, read) + } + } + } + } + } + + class MiniTarReader(private val inStream: InputStream) { + private var currentEntrySize: Long = 0 + private var bytesReadForEntry: Long = 0 + private val headerBuffer = ByteArray(512) + + class TarEntry( + val name: String, + val size: Long, + val typeFlag: Char, + val linkName: String, + val mode: Int + ) { + val isDirectory: Boolean get() = typeFlag == '5' || name.endsWith("/") + val isSymbolicLink: Boolean get() = typeFlag == '2' + } + + fun nextEntry(): TarEntry? { + val padding = (512 - (bytesReadForEntry % 512)) % 512 + if (padding > 0L) skipFully(padding) + + val read = readFully(headerBuffer) + if (read < 512) return null + if (headerBuffer.all { it == 0.toByte() }) return null + + var parsedName = parseString(0, 100) + var realName: String? = null + + if (parsedName == "././@LongLink") { + val nameSize = parseOctal(124, 12).toInt() + val nameBytes = ByteArray(nameSize) + readFully(nameBytes) + realName = String(nameBytes).trim('\u0000') + + val namePadding = (512 - (nameSize % 512)) % 512 + if (namePadding > 0) skipFully(namePadding.toLong()) + + readFully(headerBuffer) + parsedName = parseString(0, 100) + } + + val prefix = parseString(345, 155) + val finalName = realName ?: if (prefix.isNotEmpty()) "$prefix/$parsedName" else parsedName + + currentEntrySize = parseOctal(124, 12) + val typeFlag = headerBuffer[156].toInt().toChar() + val linkName = parseString(157, 100) + val mode = parseOctal(100, 8).toInt() + + bytesReadForEntry = 0 + return TarEntry(finalName, currentEntrySize, typeFlag, linkName, mode) + } + + fun readData(buffer: ByteArray): Int { + if (bytesReadForEntry >= currentEntrySize) return -1 + val toRead = minOf(buffer.size.toLong(), currentEntrySize - bytesReadForEntry).toInt() + val read = inStream.read(buffer, 0, toRead) + if (read > 0) bytesReadForEntry += read + return read + } + + private fun parseString(offset: Int, length: Int): String { + var end = offset + val limit = offset + length + while (end < limit && headerBuffer[end] != 0.toByte()) end++ + return String(headerBuffer, offset, end - offset).trim() + } + + private fun parseOctal(offset: Int, length: Int): Long { + val str = String(headerBuffer, offset, length).trim('\u0000', ' ') + return if (str.isNotEmpty()) str.toLongOrNull(8) ?: 0L else 0L + } + + private fun readFully(buffer: ByteArray): Int { + var totalRead = 0 + while (totalRead < buffer.size) { + val read = inStream.read(buffer, totalRead, buffer.size - totalRead) + if (read == -1) break + totalRead += read + } + return totalRead + } + + private fun skipFully(n: Long) { + var totalSkipped = 0L + while (totalSkipped < n) { + val skipped = inStream.skip(n - totalSkipped) + if (skipped == 0L) { + if (inStream.read() == -1) break else totalSkipped++ + } else { + totalSkipped += skipped } } } diff --git a/app/src/main/java/com/app/ralaunch/core/common/util/FileUtils.kt b/app/src/main/java/com/app/ralaunch/core/common/util/FileUtils.kt index 09ea2742..8ec4a14a 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/util/FileUtils.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/util/FileUtils.kt @@ -3,16 +3,9 @@ package com.app.ralaunch.core.common.util import android.util.Log import java.io.File import java.io.IOException -import java.nio.file.AccessDeniedException -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.NoSuchFileException -import java.nio.file.Path -import java.util.Comparator -import java.util.concurrent.atomic.AtomicBoolean /** - * 文件操作工具类 + * File operation utilities */ object FileUtils { private const val TAG = "FileUtils" @@ -20,57 +13,58 @@ object FileUtils { private const val RETRY_DELAY_MS = 100L /** - * 递归删除目录及其内容 - * @param path 要删除的路径 - * @return 删除是否成功 + * Recursively delete directory and its contents + * @param path path to delete + * @return true if deletion was successful */ @JvmStatic - fun deleteDirectoryRecursively(path: Path?): Boolean { + fun deleteDirectoryRecursively(path: File?): Boolean { if (path == null) return false - if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return true - if (!Files.isReadable(path)) return false - - val allDeleted = AtomicBoolean(true) + if (!path.exists()) return true + if (!path.canRead()) return false return try { - Files.walk(path).use { walker -> - walker.sorted(Comparator.reverseOrder()) - .forEach { p -> - if (!deletePathWithRetry(p)) { - allDeleted.set(false) - } - } + path.walkBottomUp().fold(true) { result, file -> + if (!deleteFileWithRetry(file)) { + Log.w(TAG, "Failed to delete: $file") + false + } else { + result + } } - allDeleted.get() && !Files.exists(path, LinkOption.NOFOLLOW_LINKS) - } catch (e: NoSuchFileException) { - true - } catch (e: AccessDeniedException) { - Log.w(TAG, "删除失败(权限): $path") + } catch (e: IOException) { + Log.w(TAG, "Delete failed: $path") false } catch (e: SecurityException) { - Log.w(TAG, "删除失败(权限): $path") - false - } catch (e: IOException) { - Log.w(TAG, "删除失败: $path") + Log.w(TAG, "Delete failed (permission): $path") false } catch (e: Exception) { - Log.w(TAG, "删除失败: $path", e) + Log.w(TAG, "Delete failed: $path", e) false } } /** - * 带重试机制的路径删除 + * Recursively delete directory and its contents + * @param path string path to delete + * @return true if deletion was successful + */ + @JvmStatic + fun deleteDirectoryRecursively(path: String?): Boolean { + if (path == null) return false + return deleteDirectoryRecursively(File(path)) + } + + /** + * Delete file with retry mechanism */ - private fun deletePathWithRetry(path: Path): Boolean { + private fun deleteFileWithRetry(file: File): Boolean { repeat(MAX_RETRY_ATTEMPTS) { attempt -> try { - if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return true - Files.delete(path) - return true - } catch (e: AccessDeniedException) { - return false + if (!file.exists()) return true + if (file.delete()) return true } catch (e: SecurityException) { + Log.w(TAG, "Delete denied (security): $file") return false } catch (e: IOException) { if (attempt < MAX_RETRY_ATTEMPTS - 1) { @@ -92,24 +86,49 @@ object FileUtils { } } } + Log.w(TAG, "Failed to delete after $MAX_RETRY_ATTEMPTS attempts: $file") return false } /** - * 递归删除目录及其内容(File 参数版本) - * @param directory 要删除的目录 - * @return 删除是否成功 + * Check if file exists */ @JvmStatic - fun deleteDirectoryRecursively(directory: File?): Boolean { - if (directory == null) return false - if (!directory.exists()) return true - if (!directory.canRead()) return false + fun exists(path: String?): Boolean { + if (path == null) return false + return File(path).exists() + } + /** + * Create directories including parents + */ + @JvmStatic + fun createDirectories(path: String?): Boolean { + if (path == null) return false + return File(path).mkdirs() + } + + /** + * Get file size in bytes + */ + @JvmStatic + fun getFileSize(path: String?): Long { + if (path == null) return 0L + val file = File(path) + return if (file.exists() && file.isFile) file.length() else 0L + } + + /** + * Copy file from source to destination + */ + @JvmStatic + fun copyFile(src: File, dst: File): Boolean { return try { - val path = directory.toPath().toAbsolutePath().normalize() - deleteDirectoryRecursively(path) + dst.parentFile?.mkdirs() + src.copyTo(dst, overwrite = true) + true } catch (e: Exception) { + Log.w(TAG, "Copy failed: $src -> $dst", e) false } } diff --git a/app/src/main/java/com/app/ralaunch/core/common/util/PatchExtractor.kt b/app/src/main/java/com/app/ralaunch/core/common/util/PatchExtractor.kt index 1be43c94..da23dd33 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/util/PatchExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/util/PatchExtractor.kt @@ -4,11 +4,12 @@ import android.content.Context import com.app.ralaunch.core.platform.runtime.AssemblyPatcher import com.app.ralaunch.shared.core.contract.repository.GameRepositoryV2 import org.koin.java.KoinJavaComponent -import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileOutputStream +// ... standard Java Zip import ... +import java.util.zip.ZipInputStream /** * 补丁提取工具 @@ -46,31 +47,44 @@ object PatchExtractor { if (monoModDir.exists()) FileUtils.deleteDirectoryRecursively(monoModDir) monoModDir.mkdirs() + // ... use standard Android ZipInputStream ... context.assets.open("MonoMod.zip").use { inputStream -> BufferedInputStream(inputStream, 16384).use { bis -> - ZipArchiveInputStream(bis, "UTF-8", true, true).use { zis -> - generateSequence { zis.nextZipEntry }.forEach { entry -> + ZipInputStream(bis).use { zis -> + // ... use while(true) and break on null to avoid Kotlin mutable smart-cast compilation errors ... + while (true) { + // ... val ensures it is immutable and safe for smart-casting ... + val entry = zis.nextEntry ?: break + var entryName = entry.name + + // ... strip the root folder name from the zip entry ... if (entryName.startsWith("MonoMod/") || entryName.startsWith("MonoMod\\")) { entryName = entryName.substring(8) } - if (entryName.isEmpty()) return@forEach - - val targetFile = File(monoModDir, entryName) - val canonicalDestPath = monoModDir.canonicalPath - val canonicalEntryPath = targetFile.canonicalPath - if (!canonicalEntryPath.startsWith("$canonicalDestPath${File.separator}")) return@forEach - - if (entry.isDirectory) { - targetFile.mkdirs() - } else { - targetFile.parentFile?.mkdirs() - FileOutputStream(targetFile).use { fos -> - BufferedOutputStream(fos).use { bos -> - zis.copyTo(bos, 8192) + + if (entryName.isNotEmpty()) { + val targetFile = File(monoModDir, entryName) + val canonicalDestPath = monoModDir.canonicalPath + val canonicalEntryPath = targetFile.canonicalPath + + // ... security check to prevent Zip Path Traversal vulnerability ... + if (canonicalEntryPath.startsWith("$canonicalDestPath${File.separator}")) { + if (entry.isDirectory) { + targetFile.mkdirs() + } else { + targetFile.parentFile?.mkdirs() + FileOutputStream(targetFile).use { fos -> + BufferedOutputStream(fos).use { bos -> + // ... standard stream copy ... + zis.copyTo(bos, 8192) + } + } } } } + // ... safely close current entry ... + zis.closeEntry() } } } diff --git a/app/src/main/java/com/app/ralaunch/core/common/util/TemporaryFileAcquirer.kt b/app/src/main/java/com/app/ralaunch/core/common/util/TemporaryFileAcquirer.kt index 90f79465..4385199a 100644 --- a/app/src/main/java/com/app/ralaunch/core/common/util/TemporaryFileAcquirer.kt +++ b/app/src/main/java/com/app/ralaunch/core/common/util/TemporaryFileAcquirer.kt @@ -4,41 +4,36 @@ import android.content.Context import android.util.Log import org.koin.java.KoinJavaComponent import java.io.Closeable -import java.nio.file.Path +import java.io.File -/** - * 临时文件管理器 - */ class TemporaryFileAcquirer : Closeable { - private val preferredTempDir: Path - private val tmpFilePaths = mutableListOf() + private val preferredTempDir: File + private val tmpFiles = mutableListOf() constructor() { val context: Context = KoinJavaComponent.get(Context::class.java) preferredTempDir = requireNotNull(context.externalCacheDir) - .toPath() - .toAbsolutePath() } - constructor(preferredTempDir: Path) { + constructor(preferredTempDir: File) { this.preferredTempDir = preferredTempDir } - fun acquireTempFilePath(preferredSuffix: String): Path { - val tempFilePath = preferredTempDir.resolve("${System.currentTimeMillis()}_$preferredSuffix") - tmpFilePaths.add(tempFilePath) - return tempFilePath + fun acquireTempFilePath(preferredSuffix: String): File { + val tempFile = File(preferredTempDir, "${System.currentTimeMillis()}_$preferredSuffix") + tmpFiles.add(tempFile) + return tempFile } fun cleanupTempFiles() { - tmpFilePaths.forEach { tmpFilePath -> - val isSuccessful = FileUtils.deleteDirectoryRecursively(tmpFilePath) + tmpFiles.forEach { tmpFile -> + val isSuccessful = FileUtils.deleteDirectoryRecursively(tmpFile) if (!isSuccessful) { - Log.w(TAG, "Failed to delete temporary file or directory: $tmpFilePath") + Log.w(TAG, "Failed to delete temporary file or directory: $tmpFile") } } - tmpFilePaths.clear() + tmpFiles.clear() } override fun close() { diff --git a/app/src/main/java/com/app/ralaunch/core/platform/android/ProcessLauncherService.kt b/app/src/main/java/com/app/ralaunch/core/platform/android/ProcessLauncherService.kt index ae052761..722cd74a 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/android/ProcessLauncherService.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/android/ProcessLauncherService.kt @@ -15,11 +15,8 @@ import com.app.ralaunch.feature.patch.data.PatchManager import com.app.ralaunch.core.common.util.NativeMethods import org.koin.java.KoinJavaComponent import com.app.ralaunch.core.common.util.AppLogger -import java.nio.file.Paths +import java.io.File -/** - * 通用进程启动服务 - 在独立进程中启动 .NET 程序集 - */ class ProcessLauncherService : Service() { companion object { @@ -35,7 +32,6 @@ class ProcessLauncherService : Service() { const val EXTRA_STDIN_INPUT = "stdin_input" const val ACTION_SEND_INPUT = "com.app.ralaunch.SEND_STDIN" - /** stdin 管道是否已建立 */ @Volatile private var stdinPipeReady = false @@ -56,9 +52,6 @@ class ProcessLauncherService : Service() { } } - /** - * 向服务器进程的 stdin 发送输入 - */ @JvmStatic fun sendInput(context: Context, input: String) { val intent = Intent(context, ProcessLauncherService::class.java).apply { @@ -89,7 +82,6 @@ class ProcessLauncherService : Service() { return START_NOT_STICKY } - // 处理 stdin 输入 if (intent.action == ACTION_SEND_INPUT) { val input = intent.getStringExtra(EXTRA_STDIN_INPUT) ?: return START_NOT_STICKY writeToStdin(input) @@ -140,13 +132,18 @@ class ProcessLauncherService : Service() { private fun doLaunch(assemblyPath: String, args: Array?, title: String, gameId: String?): Int { return try { - // 设置 stdin 管道:让 .NET Console.ReadLine() 可以读取我们写入的内容 setupStdinPipe() val patchManager: PatchManager? = try { KoinJavaComponent.getOrNull(PatchManager::class.java) } catch (e: Exception) { null } - val patches = if (gameId != null) patchManager?.getApplicableAndEnabledPatches(gameId, Paths.get(assemblyPath)) ?: emptyList() else emptyList() + + val patches = if (gameId != null) { + patchManager?.getApplicableAndEnabledPatches(gameId, File(assemblyPath)) ?: emptyList() + } else { + emptyList() + } + AppLogger.info(TAG, "Game: $gameId, Applicable patches: ${patches.size}") GameLauncher.launchDotNetAssembly(assemblyPath, args ?: emptyArray(), patches) } catch (e: Exception) { @@ -158,16 +155,13 @@ class ProcessLauncherService : Service() { } } - /** - * 设置 stdin 管道(通过 native 层 pipe + dup2,确保 fd 0 在 .NET 初始化前就已重定向) - */ private fun setupStdinPipe() { val writeFd = NativeMethods.setupStdinPipe() if (writeFd >= 0) { stdinPipeReady = true - AppLogger.info(TAG, "stdin 管道已建立 (native write_fd=$writeFd)") + AppLogger.info(TAG, "stdin pipe ready (native write_fd=$writeFd)") } else { - AppLogger.warn(TAG, "建立 stdin 管道失败") + AppLogger.warn(TAG, "stdin pipe setup failed") } } @@ -178,19 +172,16 @@ class ProcessLauncherService : Service() { } } - /** - * 写入内容到 stdin 管道(通过 native write()) - */ private fun writeToStdin(input: String) { if (!stdinPipeReady) { - AppLogger.warn(TAG, "stdin 管道未就绪,忽略输入: $input") + AppLogger.warn(TAG, "stdin pipe not ready, ignoring: $input") return } val result = NativeMethods.writeStdin(input) if (result >= 0) { AppLogger.info(TAG, "stdin << $input ($result bytes)") } else { - AppLogger.error(TAG, "写入 stdin 失败: $input") + AppLogger.error(TAG, "stdin write failed: $input") } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/android/provider/RaLaunchDocumentsProvider.kt b/app/src/main/java/com/app/ralaunch/core/platform/android/provider/RaLaunchDocumentsProvider.kt index fe066490..84a93fe5 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/android/provider/RaLaunchDocumentsProvider.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/android/provider/RaLaunchDocumentsProvider.kt @@ -19,18 +19,8 @@ import com.app.ralaunch.core.common.util.FileUtils import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.nio.file.Paths - -/** - * RaLaunch 文档提供器 - * 在系统文件管理器中显示启动器的所有文件目录 - * - * 显示的目录结构: - * - data/ 内部数据目录 (/data/data/com.app.ralaunch/) - * - android_data/ 外部数据目录 (/Android/data/com.app.ralaunch/files/) - * - android_obb/ OBB 目录 (/Android/obb/com.app.ralaunch/) - * - user_de_data/ 设备加密数据目录(如果存在) - */ +// XOA: import java.nio.file.Paths + class RaLaunchDocumentsProvider : DocumentsProvider() { private lateinit var mPackageName: String @@ -45,18 +35,15 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { mPackageName = context.packageName mDataDir = context.filesDir.parentFile!! - // 设备加密数据目录(Android 7.0+) val dataDirPath = mDataDir.path if (dataDirPath.startsWith("/data/user/")) { mUserDeDataDir = File("/data/user_de/" + dataDirPath.substring(11)) } - // 外部数据目录 context.getExternalFilesDir(null)?.let { mAndroidDataDir = it.parentFile } - // OBB 目录 mAndroidObbDir = context.obbDir } @@ -100,7 +87,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { val parent = getFileForDocId(docId) if (parent == null) { - // 根目录:显示所有主要目录 includeFile(result, "$docId/data", mDataDir) mAndroidDataDir?.takeIf { it.exists() }?.let { @@ -173,7 +159,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { val file = getFileForDocId(documentId) ?: throw FileNotFoundException("Failed to delete document $documentId") - // 对于符号链接,直接删除 if (isSymbolicLink(file)) { if (!file.delete()) { throw FileNotFoundException("Failed to delete document $documentId") @@ -181,8 +166,8 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { return } - // 使用 ralib 的 FileUtils 删除目录 - val success = FileUtils.deleteDirectoryRecursively(Paths.get(file.absolutePath)) + // Thay Paths.get(file.absolutePath) bang file truc tiep + val success = FileUtils.deleteDirectoryRecursively(file) if (!success) { throw FileNotFoundException("Failed to delete document $documentId") } @@ -298,14 +283,12 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { return out } - @Suppress("SameParameterValue") private fun includeFile(result: MatrixCursor, docId: String, file: File?) { var targetFile = file if (targetFile == null) { targetFile = getFileForDocId(docId) } - // 根目录 if (targetFile == null) { result.newRow().apply { add(Document.COLUMN_DOCUMENT_ID, mPackageName) @@ -318,7 +301,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { return } - // 计算标志位 var flags = 0 if (targetFile.isDirectory) { if (targetFile.canWrite()) { @@ -334,14 +316,13 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { flags = flags or Document.FLAG_SUPPORTS_MOVE } - // 确定显示名称 val path = targetFile.path var addExtras = false val displayName = when (path) { mDataDir.path -> "data" mAndroidDataDir?.path -> "android_data" mAndroidObbDir?.path -> "android_obb" - mUserDeDataDir?.path -> "user_de_data " + mUserDeDataDir?.path -> "user_de_data" else -> { addExtras = true targetFile.name @@ -357,7 +338,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { add(Document.COLUMN_FLAGS, flags) add(COLUMN_FILE_PATH, targetFile.absolutePath) - // 添加扩展信息 if (addExtras) { try { val stat = Os.lstat(path) @@ -366,9 +346,8 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { .append("|").append(stat.st_uid) .append("|").append(stat.st_gid) - // 如果是符号链接,添加链接目标 @Suppress("OctalInteger") - if ((stat.st_mode and 0x1F000) == 0xA000) { // S_IFLNK + if ((stat.st_mode and 0x1F000) == 0xA000) { sb.append("|").append(Os.readlink(path)) } add(COLUMN_FILE_EXTRAS, sb.toString()) @@ -382,24 +361,18 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { private fun getFileForDocId(docId: String, checkExists: Boolean = true): File? { var filename = docId - // 移除包名前缀 if (filename.startsWith(mPackageName)) { filename = filename.substring(mPackageName.length) } else { throw FileNotFoundException("$docId not found") } - // 移除开头的斜杠 if (filename.startsWith("/")) { filename = filename.substring(1) } - // 根目录 - if (filename.isEmpty()) { - return null - } + if (filename.isEmpty()) return null - // 解析路径类型和子路径 val i = filename.indexOf('/') val type: String val subPath: String @@ -411,7 +384,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { subPath = filename.substring(i + 1) } - // 根据类型获取基础目录 val file: File? = when { type.equals("data", ignoreCase = true) -> File(mDataDir, subPath) type.equals("android_data", ignoreCase = true) && mAndroidDataDir != null -> @@ -423,11 +395,8 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { else -> null } - if (file == null) { - throw FileNotFoundException("$docId not found") - } + if (file == null) throw FileNotFoundException("$docId not found") - // 检查文件是否存在 if (checkExists) { try { Os.lstat(file.path) @@ -487,25 +456,21 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { private fun isSymbolicLink(file: File): Boolean { return try { val stat = Os.lstat(file.path) - (stat.st_mode and 0x1F000) == 0xA000 // S_IFLNK = 0120000 + (stat.st_mode and 0x1F000) == 0xA000 } catch (e: ErrnoException) { false } } private fun getMimeTypeForFile(file: File): String { - if (file.isDirectory) { - return Document.MIME_TYPE_DIR - } + if (file.isDirectory) return Document.MIME_TYPE_DIR val name = file.name val lastDot = name.lastIndexOf('.') if (lastDot >= 0) { val extension = name.substring(lastDot + 1).lowercase() val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - if (mime != null) { - return mime - } + if (mime != null) return mime } return "application/octet-stream" diff --git a/app/src/main/java/com/app/ralaunch/core/platform/install/GameExtractorUtils.kt b/app/src/main/java/com/app/ralaunch/core/platform/install/GameExtractorUtils.kt index f0f8984c..daab0adb 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/install/GameExtractorUtils.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/install/GameExtractorUtils.kt @@ -8,21 +8,18 @@ import com.app.ralaunch.core.platform.install.extractors.GogShFileExtractor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import java.nio.file.Path -import java.nio.file.Paths /** - * 游戏解压工具类 - * 封装 ralib 中现有的解压实现 + * Game extraction utility class */ object GameExtractorUtils { /** - * 解析 GOG .sh 文件,获取游戏信息 + * Parse GOG .sh file to get game info */ suspend fun parseGogShFile(shFile: File): GogGameInfo? = withContext(Dispatchers.IO) { try { - val gdzf = GogShFileExtractor.GameDataZipFile.parseFromGogShFile(shFile.toPath()) + val gdzf = GogShFileExtractor.GameDataZipFile.parseFromGogShFile(shFile) if (gdzf != null) { GogGameInfo( id = gdzf.id ?: "", @@ -40,7 +37,7 @@ object GameExtractorUtils { } /** - * 解压 GOG .sh 文件 + * Extract GOG .sh file */ suspend fun extractGogSh( shFile: File, @@ -49,13 +46,13 @@ object GameExtractorUtils { ): ExtractResult = withContext(Dispatchers.IO) { try { val state = HashMap() - var gamePath: Path? = null + var gamePath: File? = null var success = false var errorMsg: String? = null val extractor = GogShFileExtractor( - shFile.toPath(), - outputDir.toPath(), + shFile, + outputDir, object : ExtractorCollection.ExtractionListener { override fun onProgress(message: String, progress: Float, state: HashMap?) { progressCallback(message, progress) @@ -63,7 +60,7 @@ object GameExtractorUtils { override fun onComplete(message: String, state: HashMap?) { success = true - gamePath = state?.get(GogShFileExtractor.STATE_KEY_GAME_PATH) as? Path + gamePath = state?.get(GogShFileExtractor.STATE_KEY_GAME_PATH) as? File } override fun onError(message: String, ex: Exception?, state: HashMap?) { @@ -76,7 +73,7 @@ object GameExtractorUtils { val result = extractor.extract() if (result && success) { - ExtractResult.Success(gamePath?.toFile() ?: outputDir) + ExtractResult.Success(gamePath ?: outputDir) } else { ExtractResult.Error( errorMsg ?: RaLaunchApp.getInstance().getString(R.string.extract_failed) @@ -92,11 +89,11 @@ object GameExtractorUtils { } /** - * 解压 ZIP 文件 - * @param zipFile ZIP 文件 - * @param outputDir 输出目录 - * @param progressCallback 进度回调 - * @param sourcePrefix 源路径前缀(用于只解压 ZIP 中的特定目录) + * Extract ZIP file + * @param zipFile ZIP file + * @param outputDir Output directory + * @param progressCallback Progress callback + * @param sourcePrefix Source path prefix */ suspend fun extractZip( zipFile: File, @@ -124,9 +121,9 @@ object GameExtractorUtils { } val extractor = BasicSevenZipExtractor( - zipFile.toPath(), - Paths.get(sourcePrefix), - outputDir.toPath(), + zipFile, + File(sourcePrefix), + outputDir, listener ) extractor.state = HashMap(state) @@ -149,9 +146,6 @@ object GameExtractorUtils { } } - /** - * GOG 游戏信息 - */ data class GogGameInfo( val id: String, val version: String, @@ -159,9 +153,6 @@ object GameExtractorUtils { val locale: String? = null ) - /** - * 解压结果 - */ sealed class ExtractResult { data class Success(val outputDir: File) : ExtractResult() data class Error(val message: String) : ExtractResult() diff --git a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/BasicSevenZipExtractor.kt b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/BasicSevenZipExtractor.kt index 2beccf2f..bf2e8df2 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/BasicSevenZipExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/BasicSevenZipExtractor.kt @@ -6,41 +6,31 @@ import com.app.ralaunch.RaLaunchApp import com.app.ralaunch.core.platform.runtime.RuntimeLibraryLoader import net.sf.sevenzipjbinding.* import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream -import java.io.FileNotFoundException +import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths +import java.util.zip.ZipInputStream -/** - * 基础 7-Zip 解压器 - */ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { - + companion object { private const val TAG = "BasicSevenZipExtractor" - + @Volatile private var libraryLoaded = false - - /** - * 确保 7-Zip 原生库已加载 - * 7-Zip 库保留在 APK 中,直接使用 System.loadLibrary 加载 - */ + @Synchronized fun ensureLibraryLoaded(): Boolean { if (libraryLoaded) return true - + try { - // 7-Zip 库在 APK 的 lib 目录中,直接加载 System.loadLibrary("7-Zip-JBinding") libraryLoaded = true Log.i(TAG, "7-Zip native library loaded successfully via System.loadLibrary") } catch (e: UnsatisfiedLinkError) { Log.e(TAG, "Failed to load 7-Zip native library: ${e.message}") - // 尝试从 runtime_libs 加载作为备选 try { val context = RaLaunchApp.getInstance() libraryLoaded = RuntimeLibraryLoader.load7Zip(context) @@ -57,45 +47,45 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { } } - private lateinit var sourcePath: Path - private var sourceExtractionPrefix: Path = Paths.get("") - private lateinit var destinationPath: Path + private lateinit var sourceFile: File + private var sourceExtractionPrefix: File = File("") + private lateinit var destinationFile: File private var extractionListener: ExtractorCollection.ExtractionListener? = null override var state: HashMap = hashMapOf() - constructor(sourcePath: Path, destinationPath: Path) { - setSourcePath(sourcePath) - setDestinationPath(destinationPath) + constructor(sourceFile: File, destinationFile: File) { + setSourcePath(sourceFile) + setDestinationPath(destinationFile) } - constructor(sourcePath: Path, destinationPath: Path, listener: ExtractorCollection.ExtractionListener?) { - setSourcePath(sourcePath) - setDestinationPath(destinationPath) + constructor(sourceFile: File, destinationFile: File, listener: ExtractorCollection.ExtractionListener?) { + setSourcePath(sourceFile) + setDestinationPath(destinationFile) setExtractionListener(listener) } constructor( - sourcePath: Path, - sourceExtractionPrefix: Path, - destinationPath: Path, + sourceFile: File, + sourceExtractionPrefix: File, + destinationFile: File, listener: ExtractorCollection.ExtractionListener? ) { - setSourcePath(sourcePath) - setDestinationPath(destinationPath) + setSourcePath(sourceFile) + setDestinationPath(destinationFile) setExtractionListener(listener) setSourceExtractionPrefix(sourceExtractionPrefix) } - override fun setSourcePath(sourcePath: Path) { - this.sourcePath = sourcePath + override fun setSourcePath(sourcePath: File) { + this.sourceFile = sourcePath } - fun setSourceExtractionPrefix(sourceExtractionPrefix: Path) { + fun setSourceExtractionPrefix(sourceExtractionPrefix: File) { this.sourceExtractionPrefix = sourceExtractionPrefix } - override fun setDestinationPath(destinationPath: Path) { - this.destinationPath = destinationPath + override fun setDestinationPath(destinationPath: File) { + this.destinationFile = destinationPath } override fun setExtractionListener(listener: ExtractorCollection.ExtractionListener?) { @@ -103,32 +93,121 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { } override fun extract(): Boolean { - // 确保 7-Zip 原生库已加载 - if (!ensureLibraryLoaded()) { - extractionListener?.onError( - RaLaunchApp.getInstance().getString(R.string.extract_7zip_library_load_failed), - RuntimeException("Failed to load 7-Zip native library"), - state - ) - return false + if (!destinationFile.exists()) { + destinationFile.mkdirs() } - - return try { - if (!Files.exists(destinationPath)) { - Files.createDirectories(destinationPath) + + // ... Try 7-Zip engine first ... + if (ensureLibraryLoaded()) { + try { + RandomAccessFile(sourceFile, "r").use { raf -> + RandomAccessFileInStream(raf).use { inStream -> + SevenZip.openInArchive(null, inStream).use { archive -> + val totalItems = archive.numberOfItems + Log.d(TAG, "Archive contains $totalItems items") + archive.extract(null, false, ArchiveExtractCallback(archive)) + } + } + } + + Log.d(TAG, "SevenZip extraction completed successfully") + extractionListener?.apply { + val completeMessage = RaLaunchApp.getInstance().getString(R.string.extract_complete) + onProgress(completeMessage, 1.0f, state) + onComplete(completeMessage, state) + } + return true + } catch (ex: Exception) { + Log.e(TAG, "7-Zip extraction failed: ${ex.message}", ex) + // ... DO NOT RETURN FALSE YET, FALLTHROUGH TO LIFEBOAT ... } + } else { + Log.e(TAG, "7-Zip library is not loaded. Proceeding to fallback.") + } + + // ===================================================================== + // ... THE LIFEBOAT: Pure Java Zip Fallback for older Android devices ... + // ===================================================================== + if (sourceFile.name.lowercase().endsWith(".zip")) { + Log.w(TAG, "Using Standard Java Zip Fallback for: ${sourceFile.name}") + return fallbackExtractZip() + } + + // ... If it's not a zip and 7-zip failed, then we truly failed ... + extractionListener?.onError( + RaLaunchApp.getInstance().getString(R.string.extract_7zip_failed), + RuntimeException("Both 7-Zip and Fallback failed"), + state + ) + return false + } + + // ===================================================================== + // ... PURE JAVA ZIP EXTRACTOR (With Strict Zip Slip Protection) ... + // ===================================================================== + private fun fallbackExtractZip(): Boolean { + return try { + FileInputStream(sourceFile).use { fis -> + ZipInputStream(fis).use { zis -> + var bytesExtracted = 0L + while (true) { + val entry = zis.nextEntry ?: break + + val filePath = entry.name + val prefix = sourceExtractionPrefix.path + + val relativeFilePath = if (prefix.isEmpty() || prefix == ".") { + filePath + } else if (filePath.startsWith(prefix)) { + filePath.substring(prefix.length).trimStart('/', '\\') + } else { + zis.closeEntry() + continue + } + + // ... Calculate target file and enforce STRICT Zip Slip protection ... + val targetFile = File(destinationFile, relativeFilePath).canonicalFile + val destCanonicalPath = destinationFile.canonicalPath + + // ... Append separator to ensure strict directory boundary ... + val safeDestPath = if (destCanonicalPath.endsWith(File.separator)) { + destCanonicalPath + } else { + "$destCanonicalPath${File.separator}" + } + + if (!targetFile.canonicalPath.startsWith(safeDestPath)) { + throw IOException("Zip Slip / Path traversal detected: $targetFile") + } - RandomAccessFile(sourcePath.toString(), "r").use { raf -> - RandomAccessFileInStream(raf).use { inStream -> - SevenZip.openInArchive(null, inStream).use { archive -> - val totalItems = archive.numberOfItems - Log.d(TAG, "Archive contains $totalItems items") - archive.extract(null, false, ArchiveExtractCallback(archive)) + if (entry.isDirectory) { + targetFile.mkdirs() + } else { + // ... Create parent directories ... + targetFile.parentFile?.mkdirs() + + extractionListener?.onProgress( + RaLaunchApp.getInstance().getString(R.string.extract_in_progress, filePath), + 0.5f, + state + ) + + FileOutputStream(targetFile).use { fos -> + val buffer = ByteArray(8192) + while (true) { + val read = zis.read(buffer) + if (read <= 0) break + fos.write(buffer, 0, read) + bytesExtracted += read + } + } + } + zis.closeEntry() } } } - Log.d(TAG, "SevenZip extraction completed successfully") + Log.d(TAG, "Fallback extraction completed successfully") extractionListener?.apply { val completeMessage = RaLaunchApp.getInstance().getString(R.string.extract_complete) onProgress(completeMessage, 1.0f, state) @@ -136,24 +215,21 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { } true } catch (ex: Exception) { - extractionListener?.onError( - RaLaunchApp.getInstance().getString(R.string.extract_7zip_failed), - ex, - state - ) + Log.e(TAG, "Fallback ZIP extraction also failed", ex) + extractionListener?.onError("Zip Fallback Extraction Failed", ex, state) false } } - /** - * SevenZipJBinding 提取回调实现 - */ + // ===================================================================== + // ... 7-ZIP CALLBACK HANDLER (With Strict Zip Slip Protection) ... + // ===================================================================== private inner class ArchiveExtractCallback( private val archive: IInArchive ) : IArchiveExtractCallback { private var outputStream: SequentialFileOutputStream? = null - private var currentProcessingFilePath: Path? = null + private var currentProcessingFile: File? = null private var totalBytes: Long = 0 private var totalBytesExtracted: Long = 0 @@ -162,33 +238,40 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { try { closeOutputStream() - val filePath = Paths.get(archive.getStringProperty(index, PropID.PATH)) - val isFolder = archive.getProperty(index, PropID.IS_FOLDER) as Boolean + val filePath = archive.getStringProperty(index, PropID.PATH) ?: "" + val isFolder = archive.getProperty(index, PropID.IS_FOLDER) as? Boolean ?: false - // 跳过非指定前缀的文件 - val relativeFilePath = sourceExtractionPrefix.relativize(filePath).normalize() - if (relativeFilePath.toString().startsWith("..")) { + val prefix = sourceExtractionPrefix.path + val relativeFilePath = if (prefix.isEmpty() || prefix == ".") { + filePath + } else if (filePath.startsWith(prefix)) { + filePath.substring(prefix.length).trimStart('/', '\\') + } else { return null } - // 计算目标文件路径并防止路径遍历攻击 - val targetFilePath = destinationPath.resolve(relativeFilePath).normalize() - if (destinationPath.relativize(targetFilePath).toString().startsWith("..")) { - throw SevenZipException("Attempting to write outside of destination directory: $targetFilePath") + // ... Calculate target file and enforce STRICT Zip Slip protection ... + val targetFile = File(destinationFile, relativeFilePath).canonicalFile + val destCanonicalPath = destinationFile.canonicalPath + + // ... Append separator to ensure strict directory boundary ... + val safeDestPath = if (destCanonicalPath.endsWith(File.separator)) { + destCanonicalPath + } else { + "$destCanonicalPath${File.separator}" + } + + if (!targetFile.canonicalPath.startsWith(safeDestPath)) { + throw SevenZipException("Zip Slip / Path traversal detected: $targetFile") } - // 对于文件夹只创建文件夹 if (isFolder) { - Files.createDirectories(targetFilePath) + targetFile.mkdirs() return null } - // 创建文件的父目录 - currentProcessingFilePath = targetFilePath - val targetFileParentPath = targetFilePath.normalize().parent - if (!Files.exists(targetFileParentPath)) { - Files.createDirectories(targetFileParentPath) - } + currentProcessingFile = targetFile + targetFile.parentFile?.mkdirs() val progress = if (totalBytes > 0) totalBytesExtracted.toFloat() / totalBytes else 0f extractionListener?.onProgress( @@ -197,8 +280,7 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { state ) - // 返回输出流 - outputStream = SequentialFileOutputStream(targetFilePath) + outputStream = SequentialFileOutputStream(targetFile) return outputStream } catch (e: Exception) { throw SevenZipException("Error getting stream for index $index", e) @@ -206,8 +288,7 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { } @Throws(SevenZipException::class) - override fun prepareOperation(extractAskMode: ExtractAskMode) { - } + override fun prepareOperation(extractAskMode: ExtractAskMode) {} @Throws(SevenZipException::class) override fun setOperationResult(extractOperationResult: ExtractOperationResult) { @@ -231,17 +312,14 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { it.close() outputStream = null } catch (e: IOException) { - throw SevenZipException("Error closing file: $currentProcessingFilePath") + throw SevenZipException("Error closing file: $currentProcessingFile") } } } } - /** - * SevenZipJBinding 输出流实现 - */ - private class SequentialFileOutputStream(targetFilePath: Path) : ISequentialOutStream { - private val fileStream = FileOutputStream(targetFilePath.toFile()) + private class SequentialFileOutputStream(targetFile: File) : ISequentialOutStream { + private val fileStream = FileOutputStream(targetFile) @Throws(SevenZipException::class) override fun write(data: ByteArray): Int { diff --git a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/ExtractorCollection.kt b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/ExtractorCollection.kt index 7c4c1667..308d8270 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/ExtractorCollection.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/ExtractorCollection.kt @@ -1,11 +1,8 @@ package com.app.ralaunch.core.platform.install.extractors import android.util.Log -import java.nio.file.Path +import java.io.File // Thay java.nio.file.Path -/** - * 提取器集合 - */ class ExtractorCollection { private val extractors = mutableListOf() @@ -33,29 +30,21 @@ class ExtractorCollection { }.start() } - /** - * 提取监听器接口 - */ interface ExtractionListener { fun onProgress(message: String, progress: Float, state: HashMap?) fun onComplete(message: String, state: HashMap?) fun onError(message: String, ex: Exception?, state: HashMap?) } - /** - * 提取器接口 - */ interface IExtractor { - fun setSourcePath(sourcePath: Path) - fun setDestinationPath(destinationPath: Path) + // Thay Path bang File + fun setSourcePath(sourcePath: File) + fun setDestinationPath(destinationPath: File) fun setExtractionListener(listener: ExtractionListener?) var state: HashMap fun extract(): Boolean } - /** - * 构建器 - */ class Builder { private val extractorCollection = ExtractorCollection() private val state = HashMap() @@ -79,7 +68,6 @@ class ExtractorCollection { companion object { private const val TAG = "ExtractorCollection" - const val STATE_KEY_EXTRACTOR_INDEX = "extractor_index" const val STATE_KEY_EXTRACTORS = "extractors" } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/GogShFileExtractor.kt b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/GogShFileExtractor.kt index 2c03edb9..796e6f37 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/GogShFileExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/install/extractors/GogShFileExtractor.kt @@ -4,43 +4,41 @@ import android.util.Log import com.app.ralaunch.R import com.app.ralaunch.RaLaunchApp import com.app.ralaunch.core.common.util.TemporaryFileAcquirer +import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.RandomAccessFile import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths import java.util.zip.ZipFile /** - * GOG .sh 文件提取器 + * GOG .sh File Extractor */ class GogShFileExtractor( - sourcePath: Path, - destinationPath: Path, + sourceFile: File, + destinationFile: File, listener: ExtractorCollection.ExtractionListener? ) : ExtractorCollection.IExtractor { - private lateinit var sourcePath: Path - private lateinit var destinationPath: Path + private lateinit var sourceFile: File + private lateinit var destinationFile: File private var extractionListener: ExtractorCollection.ExtractionListener? = null override var state: HashMap = hashMapOf() init { - setSourcePath(sourcePath) - setDestinationPath(destinationPath) + setSourcePath(sourceFile) + setDestinationPath(destinationFile) setExtractionListener(listener) } - override fun setSourcePath(sourcePath: Path) { - this.sourcePath = sourcePath + override fun setSourcePath(sourcePath: File) { + this.sourceFile = sourcePath } - override fun setDestinationPath(destinationPath: Path) { - this.destinationPath = destinationPath + override fun setDestinationPath(destinationPath: File) { + this.destinationFile = destinationPath } override fun setExtractionListener(listener: ExtractorCollection.ExtractionListener?) { @@ -50,26 +48,26 @@ class GogShFileExtractor( override fun extract(): Boolean { return try { TemporaryFileAcquirer().use { tfa -> - // 获取 MakeSelf SH 文件的头部信息 + // Get header info from MakeSelf SH file extractionListener?.onProgress( RaLaunchApp.getInstance().getString(R.string.extract_gog_script), 0.01f, state ) - val shFile = MakeSelfShFile.parse(sourcePath) - ?: throw IOException("解析 MakeSelf Sh 文件头部失败") + val shFile = MakeSelfShFile.parse(sourceFile) + ?: throw IOException("Failed to parse MakeSelf Sh file header") Log.d(TAG, "Successfully parsed header - offset: ${shFile.offset}, filesize: ${shFile.filesize}") - FileInputStream(sourcePath.toFile()).use { fis -> - Log.d(TAG, "Starting extraction: $sourcePath to $destinationPath") + FileInputStream(sourceFile).use { fis -> + Log.d(TAG, "Starting extraction: $sourceFile to $destinationFile") - Files.createDirectories(destinationPath) + destinationFile.mkdirs() val srcChannel = fis.channel // sanity check if (shFile.offset + shFile.filesize > srcChannel.size()) { - throw IOException("MakeSelf Sh 文件头部信息无效,超出文件总大小") + throw IOException("MakeSelf Sh file header is invalid, exceeds total file size") } extractionListener?.onProgress( @@ -78,11 +76,11 @@ class GogShFileExtractor( state ) - // 提取 mojosetup.tar.gz - val mojosetupPath = tfa.acquireTempFilePath(EXTRACTED_MOJOSETUP_TAR_GZ_FILENAME) - FileOutputStream(mojosetupPath.toFile()).use { mojosetupFos -> + // Extract mojosetup.tar.gz + val mojosetupFile = tfa.acquireTempFilePath(EXTRACTED_MOJOSETUP_TAR_GZ_FILENAME) + FileOutputStream(mojosetupFile).use { mojosetupFos -> val mojosetupChannel = mojosetupFos.channel - Log.d(TAG, "Extracting mojosetup.tar.gz to $mojosetupPath") + Log.d(TAG, "Extracting mojosetup.tar.gz to $mojosetupFile") srcChannel.transferTo(shFile.offset, shFile.filesize, mojosetupChannel) } @@ -92,11 +90,11 @@ class GogShFileExtractor( state ) - // 提取 game_data.zip - val gameDataPath = tfa.acquireTempFilePath(EXTRACTED_GAME_DATA_ZIP_FILENAME) - FileOutputStream(gameDataPath.toFile()).use { gameDataFos -> + // Extract game_data.zip + val gameDataFile = tfa.acquireTempFilePath(EXTRACTED_GAME_DATA_ZIP_FILENAME) + FileOutputStream(gameDataFile).use { gameDataFos -> val gameDataChannel = gameDataFos.channel - Log.d(TAG, "Extracting game_data.zip to $gameDataPath") + Log.d(TAG, "Extracting game_data.zip to $gameDataFile") srcChannel.transferTo( shFile.offset + shFile.filesize, srcChannel.size() - (shFile.offset + shFile.filesize), @@ -111,10 +109,10 @@ class GogShFileExtractor( ) Log.d(TAG, "Extraction from MakeSelf SH file completed successfully") - // 解压 game_data.zip + // Decompress game_data.zip Log.d(TAG, "Trying to extract game_data.zip...") - val gdzf = GameDataZipFile.parse(gameDataPath) - ?: throw IOException("解析 game_data.zip 失败") + val gdzf = GameDataZipFile.parse(gameDataFile) + ?: throw IOException("Failed to parse game_data.zip") extractionListener?.onProgress( RaLaunchApp.getInstance().getString(R.string.extract_gog_decompress_game_data), @@ -122,11 +120,11 @@ class GogShFileExtractor( state ) - val gamePath = destinationPath.resolve(Paths.get("GoG Games", gdzf.id)) + val gameDir = File(File(destinationFile, "GoG Games"), gdzf.id ?: "") val zipExtractor = BasicSevenZipExtractor( - gameDataPath, - Paths.get("data/noarch/game"), - gamePath, + gameDataFile, + File("data/noarch/game"), + gameDir, object : ExtractorCollection.ExtractionListener { override fun onProgress(message: String, progress: Float, state: HashMap?) { extractionListener?.onProgress(message, 0.1f + progress * 0.9f, state) @@ -142,15 +140,15 @@ class GogShFileExtractor( zipExtractor.state = state val isGameDataExtracted = zipExtractor.extract() if (!isGameDataExtracted) { - throw IOException("解压 game_data.zip 失败") + throw IOException("Failed to decompress game_data.zip") } - // 提取图标 + // Extract icon try { val iconExtractor = BasicSevenZipExtractor( - gameDataPath, - Paths.get("data/noarch/support"), - gamePath.resolve("support"), + gameDataFile, + File("data/noarch/support"), + File(gameDir, "support"), null ) iconExtractor.extract() @@ -160,7 +158,7 @@ class GogShFileExtractor( val completedMessage = RaLaunchApp.getInstance() .getString(R.string.extract_gog_game_data_complete) extractionListener?.onProgress(completedMessage, 1.0f, state) - state[STATE_KEY_GAME_PATH] = gamePath + state[STATE_KEY_GAME_PATH] = gameDir state[STATE_KEY_GAME_DATA_ZIP_FILE] = gdzf extractionListener?.onComplete(completedMessage, state) @@ -179,19 +177,19 @@ class GogShFileExtractor( } /** - * MakeSelf SH 文件解析器 + * MakeSelf SH File Parser */ data class MakeSelfShFile( val offset: Long, val filesize: Long ) { companion object { - fun parse(filePath: Path): MakeSelfShFile? { + fun parse(file: File): MakeSelfShFile? { val headerBuffer = ByteArray(HEADER_SIZE) val headerContent: String try { - FileInputStream(filePath.toFile()).use { fis -> + FileInputStream(file).use { fis -> val bytesRead = fis.read(headerBuffer) Log.d(TAG, "Read $bytesRead bytes from header") headerContent = String(headerBuffer, 0, bytesRead, StandardCharsets.UTF_8) @@ -286,7 +284,7 @@ class GogShFileExtractor( } /** - * 游戏数据 ZIP 文件解析器 + * Game Data ZIP File Parser */ data class GameDataZipFile( var id: String? = null, @@ -307,8 +305,8 @@ class GogShFileExtractor( const val GAMEINFO_PATH = "data/noarch/gameinfo" const val ICON_PATH = "data/noarch/support/icon.png" - fun parseFromGogShFile(filePath: Path): GameDataZipFile? { - val shFile = MakeSelfShFile.parse(filePath) ?: run { + fun parseFromGogShFile(file: File): GameDataZipFile? { + val shFile = MakeSelfShFile.parse(file) ?: run { Log.e(TAG, "MakeSelf SH file is null") return null } @@ -317,8 +315,8 @@ class GogShFileExtractor( TemporaryFileAcquirer().use { tfa -> val tempZipFile = tfa.acquireTempFilePath("temp_game_data.zip") - RandomAccessFile(filePath.toFile(), "r").use { raf -> - FileOutputStream(tempZipFile.toFile()).use { fos -> + RandomAccessFile(file, "r").use { raf -> + FileOutputStream(tempZipFile).use { fos -> val gameDataStart = shFile.offset + shFile.filesize raf.seek(gameDataStart) @@ -333,14 +331,14 @@ class GogShFileExtractor( parse(tempZipFile) } } catch (ex: Exception) { - Log.e(TAG, "Error when reading GOG SH file: $filePath", ex) + Log.e(TAG, "Error when reading GOG SH file: $file", ex) null } } - fun parse(filePath: Path): GameDataZipFile? { + fun parse(file: File): GameDataZipFile? { return try { - ZipFile(filePath.toFile()).use { zip -> + ZipFile(file).use { zip -> val gameDataZipFile = GameDataZipFile() val gameInfoContent = getFileContent(zip, GAMEINFO_PATH) @@ -371,7 +369,7 @@ class GogShFileExtractor( private fun getFileContent(zip: ZipFile, entryPath: String): String? { val entry = zip.getEntry(entryPath) if (entry == null) { - Log.w(TAG, "未在压缩包中找到 $entryPath") + Log.w(TAG, "Entry not found in zip: $entryPath") return null } return try { diff --git a/app/src/main/java/com/app/ralaunch/core/platform/install/plugins/CelesteInstallPlugin.kt b/app/src/main/java/com/app/ralaunch/core/platform/install/plugins/CelesteInstallPlugin.kt index cc544320..2618c721 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/install/plugins/CelesteInstallPlugin.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/install/plugins/CelesteInstallPlugin.kt @@ -13,9 +13,6 @@ import kotlinx.coroutines.withContext import java.io.File import java.util.zip.ZipFile -/** - * Celeste/Everest 安装插件 - */ class CelesteInstallPlugin : BaseInstallPlugin() { override val pluginId = "celeste" @@ -25,11 +22,9 @@ class CelesteInstallPlugin : BaseInstallPlugin() { override fun detectGame(gameFile: File): GameDetectResult? { val fileName = gameFile.name.lowercase() - if (fileName.endsWith(".zip") && fileName.contains("celeste")) { return GameDetectResult(GameDefinition.CELESTE) } - return null } @@ -54,15 +49,11 @@ class CelesteInstallPlugin : BaseInstallPlugin() { installJob = CoroutineScope(Dispatchers.IO).launch { try { withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_starting), - 0 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_starting), 0) } if (!gameStorageRoot.exists()) gameStorageRoot.mkdirs() - // 解压游戏本体 val extractResult = GameExtractorUtils.extractZip( zipFile = gameFile, outputDir = gameStorageRoot, @@ -81,7 +72,7 @@ class CelesteInstallPlugin : BaseInstallPlugin() { withContext(Dispatchers.Main) { callback.onError(extractResult.message) } return@launch } - is GameExtractorUtils.ExtractResult.Success -> { /* 继续 */ } + is GameExtractorUtils.ExtractResult.Success -> { } } if (isCancelled) { @@ -89,57 +80,57 @@ class CelesteInstallPlugin : BaseInstallPlugin() { return@launch } + val configFile = File(gameStorageRoot, "Celeste.runtimeconfig.json") + if (!configFile.exists()) { + val jsonContent = """ + { + "runtimeOptions": { + "tfm": "net6.0", + "rollForward": "LatestMajor", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "6.0.0" + }, + "configProperties": { + "System.GC.Server": false, + "System.GC.Concurrent": true, + "System.Runtime.TieredCompilation": true + } + } + } + """.trimIndent() + configFile.writeText(jsonContent) + } + var definition = GameDefinition.CELESTE - // 安装 Everest if (modLoaderFile != null) { withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_everest), - 55 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_everest), 55) } installEverest(modLoaderFile, gameStorageRoot, callback) definition = GameDefinition.EVEREST } - // 提取图标 withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_extract_icon), - 92 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_extract_icon), 92) } val iconPath = extractIcon(gameStorageRoot, definition) - // 创建游戏信息文件 - outputDir 既是存储根目录也是实际游戏目录 withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_finishing), - 98 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_finishing), 98) } createGameInfo(gameStorageRoot, definition, iconPath) - // 创建 GameItem 并回调 - val gameItem = createGameItem( - definition = definition, - gameDir = gameStorageRoot, - iconPath = iconPath - ) + val gameItem = createGameItem(definition, gameStorageRoot, iconPath) withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_complete), - 100 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_complete), 100) callback.onComplete(gameItem) } } catch (e: Exception) { withContext(Dispatchers.Main) { - callback.onError( - e.message ?: RaLaunchApp.getInstance().getString(R.string.install_failed) - ) + callback.onError(e.message ?: RaLaunchApp.getInstance().getString(R.string.install_failed)) } } } @@ -154,13 +145,7 @@ class CelesteInstallPlugin : BaseInstallPlugin() { if (!isCancelled) { val progressInt = 55 + (progress * 25).toInt().coerceIn(0, 25) CoroutineScope(Dispatchers.Main).launch { - callback.onProgress( - RaLaunchApp.getInstance().getString( - R.string.install_everest_with_detail, - msg - ), - progressInt - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_everest_with_detail, msg), progressInt) } } } @@ -168,59 +153,34 @@ class CelesteInstallPlugin : BaseInstallPlugin() { when (extractResult) { is GameExtractorUtils.ExtractResult.Error -> throw Exception(extractResult.message) - is GameExtractorUtils.ExtractResult.Success -> { /* 继续 */ } + is GameExtractorUtils.ExtractResult.Success -> { } } - // 安装 MonoMod 库 withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_monomod), - 85 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_monomod), 85) } installMonoMod(outputDir) - // 执行 Everest MiniInstaller withContext(Dispatchers.Main) { - callback.onProgress( - RaLaunchApp.getInstance().getString(R.string.install_everest_miniinstaller), - 90 - ) + callback.onProgress(RaLaunchApp.getInstance().getString(R.string.install_everest_miniinstaller), 90) } val patchManager: PatchManager? = try { KoinJavaComponent.getOrNull(PatchManager::class.java) } catch (e: Exception) { null } - val patches = patchManager?.getPatchesByIds( - listOf("com.app.ralaunch.everest.miniinstaller.fix") - ) ?: emptyList() + val patches = patchManager?.getPatchesByIds(listOf("com.app.ralaunch.everest.miniinstaller.fix")) ?: emptyList() if (patches.size != 1) { - throw Exception( - RaLaunchApp.getInstance().getString( - R.string.install_everest_miniinstaller_patch_missing - ) - ) + throw Exception(RaLaunchApp.getInstance().getString(R.string.install_everest_miniinstaller_patch_missing)) } - val patchResult = GameLauncher.launchDotNetAssembly( - outputDir.resolve("MiniInstaller.dll").toString(), - arrayOf(), - patches - ) + val patchResult = GameLauncher.launchDotNetAssembly(outputDir.resolve("MiniInstaller.dll").toString(), arrayOf(), patches) - outputDir.resolve("everest-launch.txt") - .writeText("# Splash screen disabled by Rotating Art Launcher\n--disable-splash\n") - outputDir.resolve("EverestXDGFlag") - .writeText("") // 创建一个空文件作为标记,告诉 Everest 使用 XDG 数据目录(Linux/MacOS) + outputDir.resolve("everest-launch.txt").writeText("# Splash screen disabled by Rotating Art Launcher\n--disable-splash\n") + outputDir.resolve("EverestXDGFlag").writeText("") if (patchResult != 0) { - throw Exception( - RaLaunchApp.getInstance().getString( - R.string.install_everest_miniinstaller_failed, - patchResult - ) - ) + throw Exception(RaLaunchApp.getInstance().getString(R.string.install_everest_miniinstaller_failed, patchResult)) } } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/AssemblyPatcher.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/AssemblyPatcher.kt index 5451c345..d4c4dc07 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/AssemblyPatcher.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/AssemblyPatcher.kt @@ -8,13 +8,9 @@ import com.app.ralaunch.core.common.util.TemporaryFileAcquirer import org.koin.java.KoinJavaComponent import java.io.File import java.io.FileOutputStream -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardCopyOption /** - * 程序集补丁工具 + * Assembly patcher utility */ object AssemblyPatcher { private const val TAG = "AssemblyPatcher" @@ -22,46 +18,48 @@ object AssemblyPatcher { private const val ASSETS_MONOMOD_ZIP = "MonoMod.zip" @JvmStatic - fun getMonoModInstallPath(): Path { + fun getMonoModInstallPath(): File { val context: Context = KoinJavaComponent.get(Context::class.java) val externalFilesDir = context.getExternalFilesDir(null) - return Paths.get(externalFilesDir?.absolutePath ?: "", MONOMOD_DIR) + return File(externalFilesDir?.absolutePath ?: "", MONOMOD_DIR) } @JvmStatic fun extractMonoMod(context: Context): Boolean { val targetDir = getMonoModInstallPath() - AppLogger.info(TAG, "正在解压 MonoMod 到 $targetDir") + AppLogger.info(TAG, "Extracting MonoMod to $targetDir") return try { TemporaryFileAcquirer().use { tfa -> - Files.createDirectories(targetDir) + targetDir.mkdirs() val tempZip = tfa.acquireTempFilePath("monomod.zip") context.assets.open(ASSETS_MONOMOD_ZIP).use { input -> - Files.copy(input, tempZip, StandardCopyOption.REPLACE_EXISTING) + FileOutputStream(tempZip).use { output -> + input.copyTo(output) + } } BasicSevenZipExtractor( - tempZip, Paths.get(""), targetDir, + tempZip, File(""), targetDir, object : ExtractorCollection.ExtractionListener { override fun onProgress(message: String, progress: Float, state: HashMap?) { - AppLogger.debug(TAG, "解压中: $message (${(progress * 100).toInt()}%)") + AppLogger.debug(TAG, "Extracting: $message (${(progress * 100).toInt()}%)") } override fun onComplete(message: String, state: HashMap?) { - AppLogger.info(TAG, "MonoMod 解压完成") + AppLogger.info(TAG, "MonoMod extraction complete") } override fun onError(message: String, ex: Exception?, state: HashMap?) { - AppLogger.error(TAG, "解压错误: $message", ex) + AppLogger.error(TAG, "Extraction error: $message", ex) } } ).extract() - AppLogger.info(TAG, "MonoMod 已解压到 $targetDir") + AppLogger.info(TAG, "MonoMod extracted to $targetDir") true } } catch (e: Exception) { - AppLogger.error(TAG, "解压 MonoMod 失败", e) + AppLogger.error(TAG, "Failed to extract MonoMod", e) false } } @@ -76,7 +74,7 @@ object AssemblyPatcher { return try { val patchAssemblies = loadPatchArchive(context) if (patchAssemblies.isEmpty()) { - if (verboseLog) AppLogger.warn(TAG, "MonoMod 目录为空或不存在") + if (verboseLog) AppLogger.warn(TAG, "MonoMod directory is empty or does not exist") return 0 } @@ -88,16 +86,16 @@ object AssemblyPatcher { val assemblyName = assemblyFile.name patchAssemblies[assemblyName]?.let { data -> if (replaceAssembly(assemblyFile, data)) { - if (verboseLog) AppLogger.debug(TAG, "已替换: $assemblyName") + if (verboseLog) AppLogger.debug(TAG, "Replaced: $assemblyName") patchedCount++ } } } - if (verboseLog) AppLogger.info(TAG, "已应用 MonoMod 补丁,替换了 $patchedCount 个文件") + if (verboseLog) AppLogger.info(TAG, "MonoMod patches applied, replaced $patchedCount files") patchedCount } catch (e: Exception) { - AppLogger.error(TAG, "应用补丁失败", e) + AppLogger.error(TAG, "Failed to apply patches", e) -1 } } @@ -105,27 +103,26 @@ object AssemblyPatcher { private fun loadPatchArchive(context: Context): Map { val assemblies = mutableMapOf() try { - val monoModPath = getMonoModInstallPath() - val monoModDir = monoModPath.toFile() + val monoModDir = getMonoModInstallPath() if (!monoModDir.exists() || !monoModDir.isDirectory) { - AppLogger.warn(TAG, "MonoMod 目录不存在: $monoModPath") + AppLogger.warn(TAG, "MonoMod directory does not exist: $monoModDir") return assemblies } val dllFiles = findDllFiles(monoModDir) - AppLogger.debug(TAG, "从 $monoModPath 找到 ${dllFiles.size} 个 DLL 文件") + AppLogger.debug(TAG, "Found ${dllFiles.size} DLL files from $monoModDir") for (dllFile in dllFiles) { try { - val assemblyData = Files.readAllBytes(dllFile.toPath()) + val assemblyData = dllFile.readBytes() assemblies[dllFile.name] = assemblyData } catch (e: Exception) { - AppLogger.warn(TAG, "读取 DLL 失败: ${dllFile.name}", e) + AppLogger.warn(TAG, "Failed to read DLL: ${dllFile.name}", e) } } } catch (e: Exception) { - AppLogger.error(TAG, "加载 MonoMod 补丁失败", e) + AppLogger.error(TAG, "Failed to load MonoMod patches", e) } return assemblies } @@ -161,7 +158,7 @@ object AssemblyPatcher { FileOutputStream(targetFile).use { it.write(assemblyData) } true } catch (e: Exception) { - AppLogger.error(TAG, "替换失败: ${targetFile.name}", e) + AppLogger.error(TAG, "Replacement failed: ${targetFile.name}", e) false } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/BlackBoxLogger.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/BlackBoxLogger.kt new file mode 100644 index 00000000..1ae5f758 --- /dev/null +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/BlackBoxLogger.kt @@ -0,0 +1,186 @@ +package com.app.ralaunch.core.platform.runtime + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Process +import android.system.Os +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BlackBoxLogger { + + private const val TAG = "BlackBoxLogger" + private var isRecording = false + private var isJavaArmed = false + private var logcatProcess: java.lang.Process? = null + + fun startRecording(context: Context) { + val crashDir = File(context.getExternalFilesDir(null), "crashreport") + if (!crashDir.exists()) crashDir.mkdirs() + + val logFile = File(crashDir, "GAME_CRASH_REPORT.txt") + if (logFile.exists()) logFile.delete() + + runCatching { File(context.filesDir, "FATAL_CRASH.txt").delete() } + + catchJavaCrashes(context, logFile) + catchNativeCrashes(logFile) + } + + fun stopRecording() { + isRecording = false + try { + logcatProcess?.destroy() + } catch (t: Throwable) {} + } + + private fun catchJavaCrashes(context: Context, logFile: File) { + if (isJavaArmed) return + isJavaArmed = true + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + writeThrowableReport(context, logFile, thread, throwable) + Thread.sleep(500) + } catch (_: Throwable) { + } finally { + defaultHandler?.uncaughtException(thread, throwable) + } + } + } + + private fun catchNativeCrashes(logFile: File) { + if (isRecording) return + + Thread { + try { + isRecording = true + val myPid = Process.myPid().toString() + + runCatching { Runtime.getRuntime().exec("logcat -c").waitFor() } + + logcatProcess = Runtime.getRuntime().exec("logcat -b crash,main -v time --pid=$myPid *:E") + + logcatProcess?.inputStream?.bufferedReader()?.use { reader -> + var isFileInitialized = false + var writer: java.io.PrintWriter? = null + + try { + while (isRecording) { + val line = reader.readLine() ?: break + + if (!isFileInitialized) { + writer = java.io.PrintWriter(java.io.FileOutputStream(logFile, true)) + writer.println("=========================================") + writer.println("💀 NATIVE / SYSTEM CRASH LOG 💀") + writer.println("=========================================") + isFileInitialized = true + } + + writer?.println(line) + writer?.flush() + } + } finally { + writer?.close() + } + } + } catch (t: Throwable) { + Log.e(TAG, "Logger malfunction", t) + } finally { + isRecording = false + } + }.start() + } + + private fun writeThrowableReport( + context: Context, + logFile: File, + thread: Thread?, + throwable: Throwable + ) { + val appVersion = try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + } catch (_: Throwable) { "Unknown" } + + val activityManager = try { + context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + } catch (_: Throwable) { null } + + val memoryInfo = try { + ActivityManager.MemoryInfo().also { info -> activityManager?.getMemoryInfo(info) } + } catch (_: Throwable) { null } + + val memoryClass = try { activityManager?.memoryClass?.toString() ?: "Unknown" } catch (_: Throwable) { "Unknown" } + val processName = try { if (Build.VERSION.SDK_INT >= 28) Application.getProcessName() else context.packageName } catch (_: Throwable) { context.packageName } + + val rootCauseElement = throwable.stackTrace.firstOrNull() + val errorFile = rootCauseElement?.fileName ?: "Unknown File" + val errorLine = rootCauseElement?.lineNumber?.toString() ?: "Unknown Line" + val errorMethod = "${rootCauseElement?.className}.${rootCauseElement?.methodName}" + + val internalFatal = File(context.filesDir, "FATAL_CRASH.txt") + val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + val vipReport = buildString { + appendLine("=========================================") + appendLine("🚨 JAVA / MANAGED CRASH REPORT 🚨") + appendLine("=========================================") + appendLine("🕒 TIME : ${timeFormat.format(Date())}") + appendLine("📱 DEVICE : ${Build.MANUFACTURER} ${Build.MODEL} (API ${Build.VERSION.SDK_INT})") + appendLine("📦 VERSION : $appVersion") + appendLine("🧵 THREAD : ${thread?.name ?: "Unknown"}") + appendLine("🧬 PROCESS : $processName") + appendLine("🏗️ ABI : ${getAbiSummary()}") + appendLine("-----------------------------------------") + appendLine("🎮 RENDERER : ${safeEnv("RALCORE_RENDERER", "native")}") + appendLine("🧪 EGL : ${safeEnv("RALCORE_EGL", "system")}") + appendLine("🧩 GLES : ${safeEnv("LIBGL_GLES", "system")}") + appendLine("📚 FNA3D : ${safeEnv("FNA3D_OPENGL_LIBRARY", "default")}") + appendLine("-----------------------------------------") + appendLine("🧠 MEM CLASS : ${memoryClass}MB") + appendLine("💾 AVAIL MEM : ${memoryInfo?.availMem?.div(1024 * 1024) ?: -1}MB") + appendLine("⚠️ LOW MEM : ${memoryInfo?.lowMemory ?: "Unknown"}") + appendLine("-----------------------------------------") + appendLine("📁 FILE : $errorFile") + appendLine("🔢 LINE : Line $errorLine") + appendLine("⚙️ METHOD : $errorMethod") + appendLine("-----------------------------------------") + appendLine("💀 TYPE : ${throwable.javaClass.name}") + appendLine("💬 MESSAGE : ${throwable.message}") + appendLine("=========================================") + appendLine("📚 STACKTRACE:") + + throwable.stackTrace.forEach { appendLine(" at $it") } + + var cause = throwable.cause + while (cause != null) { + appendLine() + appendLine("🔄 CAUSED BY: ${cause.javaClass.name}: ${cause.message}") + cause.stackTrace.forEach { appendLine(" at $it") } + cause = cause.cause + } + } + + java.io.FileOutputStream(logFile, true).use { fos -> + fos.write(vipReport.toByteArray()) + fos.write("\n\n".toByteArray()) + } + + runCatching { internalFatal.writeText(vipReport) } + } + + private fun safeEnv(key: String, fallback: String): String { + return try { Os.getenv(key) ?: fallback } catch (_: Throwable) { fallback } + } + + private fun getAbiSummary(): String { + return try { Build.SUPPORTED_ABIS.joinToString(", ") } catch (_: Throwable) { "Unknown" } + } +} diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/GameLauncher.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/GameLauncher.kt index 09b7dfb4..cb8f88b9 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/GameLauncher.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/GameLauncher.kt @@ -1,18 +1,3 @@ -/** - * 游戏启动器模块 - * Game Launcher Module - * - * 本模块是应用的核心组件,负责启动和管理 .NET/FNA 游戏进程。 - * This module is the core component of the application, responsible for launching - * and managing .NET/FNA game processes. - * - * 支持的游戏类型: - * Supported game types: - * - .NET/FNA 游戏(通过 CoreCLR 运行时) - * .NET/FNA games (via CoreCLR runtime) - * - * @see DotNetLauncher .NET 运行时启动器 / .NET runtime launcher - */ package com.app.ralaunch.core.platform.runtime import android.content.Context @@ -27,66 +12,19 @@ import com.app.ralaunch.feature.patch.data.Patch import com.app.ralaunch.feature.patch.data.PatchManager import com.app.ralaunch.core.platform.android.ProcessLauncherService import org.libsdl.app.SDL -import kotlin.io.path.Path -import kotlin.io.path.createDirectories -import kotlin.io.path.createFile -import kotlin.io.path.exists +import java.io.File -/** - * 游戏启动器 - 统一管理游戏启动流程 - * Game Launcher - Unified game launch process management - * - * 提供以下核心功能: - * Provides the following core features: - * - 启动 .NET/FNA 游戏并配置运行时环境 - * Launch .NET/FNA games with runtime environment configuration - * - 管理游戏数据目录和环境变量 - * Manage game data directories and environment variables - * - 配置补丁和启动钩子 - * Configure patches and startup hooks - */ object GameLauncher { private const val TAG = "GameLauncher" - - /** - * 默认数据目录名称 - * Default data directory name - */ private const val DEFAULT_DATA_DIR_NAME = "RALauncher" - - /** - * SDL JNI 环境是否已初始化 - * Whether SDL JNI environment is initialized - */ private var isSDLJNIInitialized = false - /** - * 重置初始化状态 - * Reset initialization state - * - * 在每次启动新游戏前调用,确保环境被正确重新初始化 - * Called before launching a new game to ensure environment is properly re-initialized - */ fun resetInitializationState() { isSDLJNIInitialized = false AppLogger.info(TAG, "初始化状态已重置 / Initialization state reset") } - /** - * 静态初始化块 - 加载所有必需的 Native 库 - * Static initializer - Load all required native libraries - * - * 加载顺序很重要,某些库依赖于其他库。 - * Loading order matters, some libraries depend on others. - * - * 包含的库: - * Included libraries: - * - FMOD: 音频引擎(Celeste 等游戏需要)/ Audio engine (required by games like Celeste) - * - SDL2: 跨平台多媒体库 / Cross-platform multimedia library - * - FAudio: XAudio2 替代实现 / XAudio2 reimplementation - * - SkiaSharp: 2D 图形库 / 2D graphics library - */ init { try { System.loadLibrary("fmodL") @@ -101,103 +39,28 @@ object GameLauncher { System.loadLibrary("openal32") System.loadLibrary("lwjgl_lz4") System.loadLibrary("SkiaSharp") - } catch (e: UnsatisfiedLinkError) { AppLogger.error(TAG, "加载 Native 库失败 / Failed to load native libraries: ${e.message}") } } - /** - * 获取最后一次错误信息 - * Get the last error message - * - * @return 错误信息字符串,如果没有错误则返回空字符串 - * Error message string, or empty string if no error - */ fun getLastErrorMessage(): String { return DotNetLauncher.hostfxrLastErrorMsg } - /** - * 初始化 SDL JNI 环境 - * Initialize SDL JNI environment - * - * 重要:此方法只设置 JNI 环境和 Context,不会调用 SDL.initialize()。 - * Important: This method only sets up JNI environment and Context, - * it does NOT call SDL.initialize(). - * - * 原因:当从 GameActivity(继承自 SDLActivity)启动时, - * SDLActivity.onCreate() 已经初始化了 SDL。 - * 再次调用 SDL.initialize() 会重置 mSingleton 和 mSurface 为 null。 - * - * Reason: When launched from GameActivity (extends SDLActivity), - * SDLActivity.onCreate() has already initialized SDL. - * Calling SDL.initialize() again would reset mSingleton and mSurface to null. - * - * @param context Android 上下文,供 SDL 音频系统使用 - * Android context for SDL audio system - */ private fun initializeSDLJNI(context: Context) { if (isSDLJNIInitialized) return - try { AppLogger.info(TAG, "正在初始化 SDL JNI 环境 / Initializing SDL JNI environment...") SDL.setupJNI() - - // 只设置 Context,不调用 initialize() - // Only set Context, do NOT call initialize() SDL.setContext(context) isSDLJNIInitialized = true - - AppLogger.info(TAG, "SDL JNI 初始化成功(未重新初始化 SDLActivity)/ SDL JNI initialized successfully (without re-initializing SDLActivity)") + AppLogger.info(TAG, "SDL JNI 初始化成功 / SDL JNI initialized successfully") } catch (e: Exception) { AppLogger.warn(TAG, "SDL JNI 初始化失败 / Failed to initialize SDL JNI: ${e.message}") } } - /** - * 启动 .NET 程序集 - * Launch a .NET assembly - * - * 此方法负责准备游戏运行环境并调用 .NET 运行时启动游戏。 - * 只处理游戏相关的环境配置,底层运行时环境变量由 DotNetLauncher 处理。 - * - * This method prepares the game runtime environment and calls .NET runtime to launch the game. - * Only handles game-related environment configuration, low-level runtime environment - * variables are handled by DotNetLauncher. - * - * 启动流程: - * Launch process: - * 1. 验证程序集文件存在 - * Verify assembly file exists - * 2. 设置环境变量(包名、存储路径等) - * Set environment variables (package name, storage path, etc.) - * 3. 切换工作目录到程序集所在目录 - * Change working directory to assembly location - * 4. 准备数据目录(HOME、XDG_* 等) - * Prepare data directories (HOME, XDG_*, etc.) - * 5. 配置补丁和启动钩子 - * Configure patches and startup hooks - * 6. 配置渲染器和线程亲和性 - * Configure renderer and thread affinity - * 7. 调用 hostfxr 启动 .NET 运行时 - * Call hostfxr to launch .NET runtime - * - * @param assemblyPath 程序集(.dll)的完整路径 - * Full path to the assembly (.dll) - * @param args 传递给程序集的命令行参数 - * Command line arguments to pass to the assembly - * @param enabledPatches 要启用的补丁列表,null 表示不使用补丁 - * List of patches to enable, null means no patches - * @param rendererOverride 可选的渲染器覆盖(null 表示使用全局设置) - * Optional renderer override (null means use global setting) - * @param gameEnvVars 游戏环境变量(null 值表示在启动前 unset 对应变量) - * Game environment variables (null value means unset before launch) - * @return 程序集退出代码: - * Assembly exit code: - * - 0 或正数:正常退出码 / Normal exit code - * - -1:启动失败(文件不存在或发生异常)/ Launch failed (file not found or exception) - */ fun launchDotNetAssembly( assemblyPath: String, args: Array, @@ -208,36 +71,22 @@ object GameLauncher { try { AppLogger.info(TAG, "=== 开始启动 .NET 程序集 / Starting .NET Assembly Launch ===") AppLogger.info(TAG, "程序集路径 / Assembly path: $assemblyPath") - AppLogger.info(TAG, "启动参数 / Arguments: ${args.joinToString(", ")}") - AppLogger.info(TAG, "启用补丁数 / Enabled patches: ${enabledPatches?.size ?: 0}") - // 步骤1:验证程序集文件存在 - // Step 1: Verify assembly file exists - if (!Path(assemblyPath).exists()) { + if (!File(assemblyPath).exists()) { AppLogger.error(TAG, "程序集文件不存在 / Assembly file does not exist: $assemblyPath") return -1 } - AppLogger.debug(TAG, "程序集文件验证通过 / Assembly file exists: OK") - // 步骤2:设置基础环境变量 - // Step 2: Set basic environment variables - AppLogger.debug(TAG, "设置环境变量 / Setting up environment variables...") val appContext: Context = KoinJavaComponent.get(Context::class.java) EnvVarsManager.quickSetEnvVars( "PACKAGE_NAME" to appContext.packageName, "EXTERNAL_STORAGE_DIRECTORY" to Environment.getExternalStorageDirectory().path ) - // 步骤3:切换工作目录 - // Step 3: Change working directory - val workingDir = Path(assemblyPath).parent.toString() + val workingDir = File(assemblyPath).parent ?: "" AppLogger.debug(TAG, "切换工作目录 / Changing working directory to: $workingDir") NativeMethods.chdir(workingDir) - AppLogger.debug(TAG, "工作目录切换完成 / Working directory changed: OK") - // 步骤4:准备数据目录 - // Step 4: Prepare data directory - AppLogger.debug(TAG, "准备数据目录 / Preparing data directory...") val dataDir = prepareDataDirectory(assemblyPath) val cacheDir = appContext.cacheDir.absolutePath AppLogger.info(TAG, "数据目录 / Data directory: $dataDir") @@ -249,57 +98,21 @@ object GameLauncher { "XDG_CACHE_HOME" to cacheDir, "TMPDIR" to cacheDir ) - AppLogger.debug(TAG, "XDG 环境变量设置完成 / XDG environment variables set: OK") - // 步骤5:应用用户设置 - // Step 5: Apply user settings val settings = SettingsAccess - AppLogger.debug(TAG, "应用用户设置 / Applying settings configuration...") - AppLogger.debug(TAG, " - 大核亲和性 / Big core affinity: ${settings.setThreadAffinityToBigCoreEnabled}") - AppLogger.debug(TAG, " - 多点触控 / Touch multitouch: ${settings.isTouchMultitouchEnabled}") - AppLogger.debug(TAG, " - 鼠标右摇杆 / Mouse right stick: ${settings.isMouseRightStickEnabled}") - - // 步骤6:配置启动钩子(补丁) - // Step 6: Configure startup hooks (patches) val startupHooks = if (enabledPatches != null && enabledPatches.isNotEmpty()) PatchManager.constructStartupHooksEnvVar(enabledPatches) else null - if (startupHooks != null) { - AppLogger.info(TAG, "已配置 ${enabledPatches!!.size} 个补丁的启动钩子 / DOTNET_STARTUP_HOOKS configured with ${enabledPatches.size} patch(es)") - AppLogger.debug(TAG, "DOTNET_STARTUP_HOOKS 值 / value: $startupHooks") - val hookCount = startupHooks.split(":").filter { it.isNotEmpty() }.size - AppLogger.debug(TAG, "实际钩子数量 / Actual hook count: $hookCount") - } else { - AppLogger.debug(TAG, "未配置启动钩子 / No startup hooks configured") - } - - // 步骤7:设置 MonoMod 路径(供补丁使用) - // Step 7: Set MonoMod path (for patches) val monoModPath = AssemblyPatcher.getMonoModInstallPath().toString() - AppLogger.info(TAG, "MonoMod 路径 / path: $monoModPath") EnvVarsManager.quickSetEnvVars( - // 启动钩子配置 - // Startup hooks configuration "DOTNET_STARTUP_HOOKS" to startupHooks, - - // MonoMod 路径,供补丁的 AssemblyResolve 使用 - // MonoMod path, used by patch's AssemblyResolve "MONOMOD_PATH" to monoModPath, - - // 触摸输入配置 - // Touch input configuration "SDL_TOUCH_MOUSE_EVENTS" to "1", "SDL_TOUCH_MOUSE_MULTITOUCH" to if (settings.isTouchMultitouchEnabled) "1" else "0", "RALCORE_MOUSE_RIGHT_STICK" to if (settings.isMouseRightStickEnabled) "1" else null, - - // 音频配置 - // Audio configuration "SDL_AAUDIO_LOW_LATENCY" to if (settings.isSdlAaudioLowLatency) "1" else "0", "RAL_AUDIO_BUFFERSIZE" to settings.ralAudioBufferSize?.toString(), - - // OpenGL 运行时诊断(用于 FPS 旁性能分析) - // OpenGL runtime diagnostics (for FPS-adjacent performance analysis) "RAL_GL_DIAGNOSTICS" to if ( settings.isFnaGlPerfDiagnosticsEnabled && settings.isFPSDisplayEnabled ) "1" else "0", @@ -322,31 +135,17 @@ object GameLauncher { "RAL_GL_MAP_RATIO" to null, "RAL_GL_MAP_ENABLED" to null, ) - AppLogger.debug(TAG, "游戏设置环境变量配置完成 / Game settings environment variables set: OK") - // 步骤8:配置渲染器 - // Step 8: Configure renderer - AppLogger.debug(TAG, "配置渲染器环境 / Applying renderer environment...") RendererEnvironmentConfigurator.apply( context = appContext, rendererOverride = rendererOverride ) - AppLogger.debug(TAG, "渲染器环境配置完成 / Renderer environment applied: OK") - // 步骤9:设置线程亲和性 - // Step 9: Set thread affinity if (settings.setThreadAffinityToBigCoreEnabled) { - AppLogger.debug(TAG, "设置线程亲和性到大核 / Setting thread affinity to big cores...") - val result = ThreadAffinityManager.setThreadAffinityToBigCores() - AppLogger.debug(TAG, "线程亲和性设置完成 / Thread affinity to big cores set: Result=$result") - } else { - AppLogger.debug(TAG, "未启用大核亲和性,跳过 / Thread affinity to big cores not enabled, skipping.") + ThreadAffinityManager.setThreadAffinityToBigCores() } - // 步骤10:应用游戏级环境变量(优先级高于全局/渲染器配置) - // Step 10: Apply per-game env vars (higher priority than global/renderer config) if (gameEnvVars.isNotEmpty()) { - AppLogger.debug(TAG, "应用游戏环境变量 / Applying per-game env vars: ${gameEnvVars.size} item(s)") val availableInterpolations = linkedMapOf( "PACKAGE_NAME" to appContext.packageName, "EXTERNAL_STORAGE_DIRECTORY" to Environment.getExternalStorageDirectory().path, @@ -364,131 +163,54 @@ object GameLauncher { availableInterpolations = availableInterpolations ) EnvVarsManager.quickSetEnvVars(resolvedGameEnvVars) - AppLogger.debug(TAG, "游戏环境变量应用完成 / Per-game env vars applied: OK") } - // 步骤11:启动 .NET 运行时 - // Step 11: Launch .NET runtime - AppLogger.info(TAG, "通过 hostfxr 启动 .NET 运行时 / Launching .NET runtime with hostfxr...") val result = DotNetLauncher.hostfxrLaunch(assemblyPath, args) - - AppLogger.info(TAG, "=== .NET 程序集启动完成 / .NET Assembly Launch Completed ===") AppLogger.info(TAG, "退出代码 / Exit code: $result") - return result + } catch (e: Exception) { AppLogger.error(TAG, "启动程序集失败 / Failed to launch assembly: $assemblyPath", e) - e.printStackTrace() return -1 } } - /** - * 在新进程中启动 .NET 程序集 - * Launch a .NET assembly in a new process - * - * 此方法由 Native 层调用,用于在独立进程中启动子程序集。 - * 例如:某些游戏可能需要启动额外的工具或服务器进程。 - * - * This method is called from native layer to launch sub-assemblies in separate processes. - * For example: some games may need to launch additional tools or server processes. - * - * @param assemblyPath 程序集的完整路径 - * Full path to the assembly - * @param args 传递给程序集的命令行参数 - * Command line arguments for the assembly - * @param title 进程标题,用于日志和调试 - * Process title for logging and debugging - * @param gameId 游戏标识符,用于匹配相关补丁 - * Game identifier for matching related patches - * @return 启动结果:0 表示成功,-1 表示失败 - * Launch result: 0 for success, -1 for failure - */ @JvmStatic fun launchNewDotNetProcess(assemblyPath: String, args: Array, title: String, gameId: String): Int { - try { - AppLogger.info(TAG, "=== 收到新进程启动请求 / launchNewDotNetProcess called ===") - AppLogger.info(TAG, "程序集 / Assembly: $assemblyPath") - AppLogger.info(TAG, "标题 / Title: $title") - AppLogger.info(TAG, "游戏ID / Game ID: $gameId") - AppLogger.info(TAG, "参数 / Arguments: ${args.joinToString(", ")}") - + return try { ProcessLauncherService.launch(assemblyPath, args, title, gameId) - - return 0 + 0 } catch (e: Exception) { AppLogger.error(TAG, "启动新 .NET 进程失败 / Failed to launch new .NET process", e) - return -1 + -1 } } - /** - * 准备游戏数据目录 - * Prepare game data directory - * - * 创建并返回游戏存档、配置等数据的存储目录。 - * 默认使用外部存储的 RALauncher 目录,如果无法访问则回退到程序集所在目录。 - * - * Creates and returns the storage directory for game saves, configs, etc. - * Defaults to RALauncher directory in external storage, - * falls back to assembly directory if inaccessible. - * - * 目录结构: - * Directory structure: - * - /storage/emulated/0/RALauncher/ - * - .nomedia(防止媒体扫描 / Prevents media scanning) - * - [游戏存档和配置 / Game saves and configs] - * - * @param assemblyPath 程序集路径,用于获取回退目录 - * Assembly path for fallback directory - * @return 数据目录的绝对路径 - * Absolute path to the data directory - */ private fun prepareDataDirectory(assemblyPath: String): String { - // 初始回退目录为程序集所在目录 - // Initial fallback is the assembly's parent directory - var finalDataDir = Path(assemblyPath).parent - AppLogger.debug(TAG, "初始数据目录(程序集父目录)/ Initial data directory (assembly parent): $finalDataDir") + var finalDataDir = File(assemblyPath).parentFile?.absolutePath ?: "" try { - // 尝试使用外部存储的默认数据目录 - // Try to use default data directory in external storage - val defaultDataDirPath = android.os.Environment.getExternalStorageDirectory() - .resolve(DEFAULT_DATA_DIR_NAME) - .toPath() - - AppLogger.debug(TAG, "目标数据目录 / Target data directory: $defaultDataDirPath") + val defaultDataDir = File( + Environment.getExternalStorageDirectory(), + DEFAULT_DATA_DIR_NAME + ) - // 创建目录(如果不存在) - // Create directory if it doesn't exist - if (!defaultDataDirPath.exists()) { - AppLogger.debug(TAG, "创建数据目录 / Creating data directory: $defaultDataDirPath") - defaultDataDirPath.createDirectories() - AppLogger.debug(TAG, "数据目录创建成功 / Data directory created: OK") - } else { - AppLogger.debug(TAG, "数据目录已存在 / Data directory already exists") + if (!defaultDataDir.exists()) { + defaultDataDir.mkdirs() } - // 创建 .nomedia 文件防止媒体扫描 - // Create .nomedia file to prevent media scanning - val nomediaFilePath = defaultDataDirPath.resolve(".nomedia") - if (!nomediaFilePath.exists()) { - AppLogger.debug(TAG, "创建 .nomedia 文件 / Creating .nomedia file: $nomediaFilePath") - nomediaFilePath.createFile() - AppLogger.debug(TAG, ".nomedia 文件创建成功 / .nomedia file created: OK") - } else { - AppLogger.debug(TAG, ".nomedia 文件已存在 / .nomedia file already exists") + val nomediaFile = File(defaultDataDir, ".nomedia") + if (!nomediaFile.exists()) { + nomediaFile.createNewFile() } - finalDataDir = defaultDataDirPath + finalDataDir = defaultDataDir.absolutePath AppLogger.info(TAG, "使用默认数据目录 / Using default data directory: $finalDataDir") + } catch (e: Exception) { - // 无法访问外部存储,使用程序集目录作为回退 - // Cannot access external storage, use assembly directory as fallback - AppLogger.warn(TAG, "无法访问默认数据目录,使用程序集目录 / Failed to access default data directory, using assembly directory instead.", e) - AppLogger.warn(TAG, "回退数据目录 / Fallback data directory: $finalDataDir") + AppLogger.warn(TAG, "无法访问默认数据目录 / Failed to access default data directory, using fallback: $finalDataDir", e) } - return finalDataDir.toString() + return finalDataDir } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RuntimeLibraryLoader.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RuntimeLibraryLoader.kt index f20f0c4f..86ef3c13 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RuntimeLibraryLoader.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RuntimeLibraryLoader.kt @@ -4,9 +4,10 @@ import android.content.Context import com.app.ralaunch.R import com.app.ralaunch.RaLaunchApp import com.app.ralaunch.core.common.util.AppLogger +import com.app.ralaunch.core.common.util.ArchiveExtractor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +// ... Removed Apache TarArchiveInputStream to prevent crash ... import org.tukaani.xz.XZInputStream import java.io.BufferedInputStream import java.io.File @@ -175,12 +176,15 @@ object RuntimeLibraryLoader { context.assets.open(RUNTIME_LIBS_ARCHIVE).use { assetStream -> val bufferedStream = BufferedInputStream(assetStream, 1024 * 1024) val xzStream = XZInputStream(bufferedStream) - val tarStream = TarArchiveInputStream(xzStream) - var entry = tarStream.nextEntry + // ... Use our custom MiniTarReader instead of Apache ... + val tarReader = ArchiveExtractor.MiniTarReader(xzStream) + var extractedCount = 0 + val buffer = ByteArray(8192) // ... buffer for custom copy ... - while (entry != null) { + while (true) { + val entry = tarReader.nextEntry() ?: break val outputFile = File(runtimeDir, entry.name) if (entry.isDirectory) { @@ -189,7 +193,12 @@ object RuntimeLibraryLoader { outputFile.parentFile?.mkdirs() FileOutputStream(outputFile).use { fos -> - tarStream.copyTo(fos) + // ... Custom write loop ... + while (true) { + val read = tarReader.readData(buffer) + if (read <= 0) break + fos.write(buffer, 0, read) + } } // 设置库文件权限(可读 + 可执行) @@ -211,8 +220,6 @@ object RuntimeLibraryLoader { AppLogger.debug(TAG, "Extracted: ${entry.name}") } - - entry = tarStream.nextEntry } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/dotnet/DotNetNativeLibraryLoader.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/dotnet/DotNetNativeLibraryLoader.kt index 73db0b6d..0a493208 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/dotnet/DotNetNativeLibraryLoader.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/dotnet/DotNetNativeLibraryLoader.kt @@ -2,7 +2,7 @@ package com.app.ralaunch.core.platform.runtime.dotnet import android.util.Log import java.io.File -import java.nio.file.Paths +// ... REMOVED: import java.nio.file.Paths to prevent Android 7 crash ... /** * .NET Native 库加载器 @@ -42,7 +42,8 @@ object DotNetNativeLibraryLoader { return true } return try { - val runtimePath = Paths.get(dotnetRoot, "shared/Microsoft.NETCore.App/$runtimeVersion").toString() + // ... REPLACED: Paths.get(...) with standard File constructors ... + val runtimePath = File(File(dotnetRoot, "shared/Microsoft.NETCore.App"), runtimeVersion).absolutePath loadAllLibrariesInternal(runtimePath) } catch (e: Exception) { Log.e(TAG, "❌ 加载 .NET Native 库失败", e) @@ -72,7 +73,8 @@ object DotNetNativeLibraryLoader { private fun loadLibrary(basePath: String, libName: String, required: Boolean) { try { - val fullPath = Paths.get(basePath, libName).toString() + // ... REPLACED: Paths.get(...) with standard File constructors ... + val fullPath = File(basePath, libName).absolutePath Log.i(TAG, "正在加载: $libName") System.load(fullPath) Log.i(TAG, " ✓ $libName 加载成功") @@ -102,7 +104,9 @@ object DotNetNativeLibraryLoader { val version = versions[0] Log.i(TAG, "检测到运行时版本: $version") - Paths.get(dotnetRoot, "shared/Microsoft.NETCore.App/$version").toString() + + // ... REPLACED: Paths.get(...) with standard File constructors ... + File(runtimeDir, version).absolutePath } catch (e: Exception) { Log.e(TAG, "查找运行时路径失败", e) null diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererEnvironmentConfigurator.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererEnvironmentConfigurator.kt index 3432c400..9f9a8a22 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererEnvironmentConfigurator.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererEnvironmentConfigurator.kt @@ -39,6 +39,7 @@ object RendererEnvironmentConfigurator { val overrideCompatible = normalizedOverride?.let { renderer -> context == null || AndroidRendererRegistry.isRendererCompatible(renderer) } ?: true + val renderer = resolveRendererForLaunch( globalEffectiveRenderer = globalRenderer, rendererOverride = rendererOverride, @@ -48,16 +49,10 @@ object RendererEnvironmentConfigurator { if (rendererOverride != null) { val rawOverride = rendererOverride.trim() when { - rawOverride.isEmpty() || !AndroidRendererRegistry.isKnownRendererId(rawOverride) -> - Log.w( - TAG, - "Renderer override is invalid: $rendererOverride, fallback to global: $globalRenderer" - ) + rawOverride.isEmpty() || !RendererRegistry.isKnownRendererId(rawOverride) -> + Log.w(TAG, "Renderer override is invalid: $rendererOverride, fallback to global: $globalRenderer") !overrideCompatible -> - Log.w( - TAG, - "Renderer override is incompatible on this device: $rawOverride, fallback to global: $globalRenderer" - ) + Log.w(TAG, "Renderer override is incompatible on this device: $rawOverride, fallback to global: $globalRenderer") else -> Log.i(TAG, "Using per-game renderer override: ${RendererRegistry.normalizeRendererId(rawOverride)}") } @@ -67,6 +62,7 @@ object RendererEnvironmentConfigurator { applyFna3dEnvironment(renderer) Log.i(TAG, "Renderer environment applied successfully for: $renderer") + Log.i(TAG, "Effective renderer after apply: ${RendererLoader.getCurrentRenderer()}") } private fun loadRendererLibraries(context: Context?, renderer: String) { @@ -89,16 +85,21 @@ object RendererEnvironmentConfigurator { private fun buildFna3dEnvVars(renderer: String): Map { val envVars = mutableMapOf() - envVars["FNA3D_OPENGL_DRIVER"] = renderer envVars["FNA3D_FORCE_DRIVER"] = "OpenGL" + envVars["FNA3D_OPENGL_DRIVER"] = mapRendererToFnaDriver(renderer) envVars.putAll(getOpenGlVersionConfig(renderer)) -// envVars["FNA3D_OPENGL_USE_MAP_BUFFER_RANGE"] = getMapBufferRangeValue(renderer) envVars.putAll(getQualityConfig()) -// envVars["SDL_RENDER_VSYNC"] = "1" return envVars } + private fun mapRendererToFnaDriver(renderer: String): String { + return when (renderer) { + RendererRegistry.ID_MOBILEGLUES -> "mobileglues" + else -> "OpenGL" + } + } + private fun getQualityConfig(): Map { val settings = SettingsAccess val envVars = mutableMapOf() @@ -192,24 +193,26 @@ object RendererEnvironmentConfigurator { Log.i(TAG, "OpenGL Profile: Desktop OpenGL 2.1 Compatibility Profile") AndroidRendererRegistry.ID_ZINK -> Log.i(TAG, "OpenGL Profile: Desktop OpenGL 4.3 (Mesa Zink over Vulkan)") - else -> + RendererRegistry.ID_ANGLE, + RendererRegistry.ID_GL4ES_ANGLE, + RendererRegistry.ID_NATIVE, + RendererRegistry.ID_MOBILEGLUES -> Log.i(TAG, "OpenGL Profile: OpenGL ES 3.0") + else -> + Log.i(TAG, "OpenGL Profile: OpenGL ES") } val mapBufferRange = envVars["FNA3D_OPENGL_USE_MAP_BUFFER_RANGE"] when { - mapBufferRange == "0" && renderer in setOf( - AndroidRendererRegistry.ID_ANGLE, - AndroidRendererRegistry.ID_GL4ES_ANGLE - ) -> - Log.i(TAG, "Map Buffer Range: Disabled (Vulkan-translated renderer)") + mapBufferRange == "0" && renderer in setOf(RendererRegistry.ID_ANGLE, RendererRegistry.ID_GL4ES_ANGLE) -> + Log.i(TAG, "Map Buffer Range: Disabled (translated renderer)") mapBufferRange == "0" -> Log.i(TAG, "Map Buffer Range: Disabled (via settings)") else -> Log.i(TAG, "Map Buffer Range: Enabled by default") } - Log.i(TAG, "VSync: Forced ON") + Log.i(TAG, "VSync: Controlled by runtime/settings") Log.i(TAG, "===========================") } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererLoader.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererLoader.kt index 70fe29c7..f41386f2 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererLoader.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/renderer/RendererLoader.kt @@ -2,16 +2,13 @@ package com.app.ralaunch.core.platform.runtime.renderer import android.content.Context import android.system.Os -import com.app.ralaunch.core.platform.runtime.EnvVarsManager import com.app.ralaunch.core.common.util.AppLogger -import com.app.ralaunch.shared.core.platform.runtime.renderer.AndroidRendererRegistry -import com.app.ralaunch.shared.core.platform.runtime.renderer.RendererRegistry +import com.app.ralaunch.core.platform.runtime.EnvVarsManager +import java.io.File -/** - * 渲染器加载器 - 基于环境变量的简化实现 - */ object RendererLoader { private const val TAG = "RendererLoader" + private const val RUNTIME_LIBS_DIR = "runtime_libs" fun loadRenderer(context: Context, renderer: String): Boolean { return try { @@ -22,44 +19,116 @@ object RendererLoader { return false } - if (!AndroidRendererRegistry.isRendererCompatible(normalizedRenderer)) { - AppLogger.error(TAG, "Renderer is not compatible with this device") + if (!RendererRegistry.isRendererCompatible(normalizedRenderer)) { + AppLogger.error(TAG, "Renderer is not compatible with this device: $normalizedRenderer") return false } - val envMap = AndroidRendererRegistry.buildRendererEnv(normalizedRenderer) - EnvVarsManager.quickSetEnvVars(envMap) - - if (rendererInfo.needsPreload && rendererInfo.eglLibrary != null) { - try { - val eglLibPath = AndroidRendererRegistry.getRendererLibraryPath(rendererInfo.eglLibrary) - EnvVarsManager.quickSetEnvVar("FNA3D_OPENGL_LIBRARY", eglLibPath) - } catch (e: UnsatisfiedLinkError) { - AppLogger.error(TAG, "Failed to preload renderer library: ${e.message}") - } - } - val nativeLibDir = context.applicationInfo.nativeLibraryDir + val runtimeLibsDir = File(context.filesDir, RUNTIME_LIBS_DIR) + EnvVarsManager.quickSetEnvVar("RALCORE_NATIVEDIR", nativeLibDir) - - // 设置 runtime_libs 目录路径(从 tar.xz 解压的库) - val runtimeLibsDir = java.io.File(context.filesDir, "runtime_libs") + if (runtimeLibsDir.exists()) { val runtimePath = runtimeLibsDir.absolutePath EnvVarsManager.quickSetEnvVar("RALCORE_RUNTIMEDIR", runtimePath) - AppLogger.info(TAG, "RALCORE_RUNTIMEDIR = $runtimePath") - - // 设置 LD_LIBRARY_PATH 包含 runtime_libs 目录,让 dlopen 能找到库 + val currentLdPath = Os.getenv("LD_LIBRARY_PATH") ?: "" - val newLdPath = if (currentLdPath.isNotEmpty()) { - "$runtimePath:$nativeLibDir:$currentLdPath" - } else { - "$runtimePath:$nativeLibDir" + val newLdPath = buildString { + append(runtimePath) + append(":") + append(nativeLibDir) + if (currentLdPath.isNotEmpty()) { + append(":") + append(currentLdPath) + } } EnvVarsManager.quickSetEnvVar("LD_LIBRARY_PATH", newLdPath) + AppLogger.info(TAG, "RALCORE_RUNTIMEDIR = $runtimePath") AppLogger.info(TAG, "LD_LIBRARY_PATH = $newLdPath") } + val envMap = RendererRegistry.buildRendererEnv(normalizedRenderer).toMutableMap() + + val eglPath = RendererRegistry.getRendererLibraryPath(rendererInfo.eglLibrary) + val glesPath = RendererRegistry.getRendererLibraryPath(rendererInfo.glesLibrary) + val preloadPaths = RendererRegistry.getRendererPreloadLibraryPaths(normalizedRenderer) + + if (!eglPath.isNullOrEmpty()) { + envMap["RALCORE_EGL_PATH"] = eglPath + } + + if (!glesPath.isNullOrEmpty()) { + envMap["RALCORE_GLES_PATH"] = glesPath + } + + when (normalizedRenderer) { + RendererRegistry.ID_ANGLE -> { + if (!glesPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = glesPath + } + } + + RendererRegistry.ID_GL4ES -> { + val gl4esPath = RendererRegistry.getRendererLibraryPath("libGL_gl4es.so") + if (!gl4esPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = gl4esPath + } + } + + RendererRegistry.ID_GL4ES_ANGLE -> { + val gl4esPath = RendererRegistry.getRendererLibraryPath("libGL_gl4es.so") + val angleEglPath = RendererRegistry.getRendererLibraryPath("libEGL_angle.so") + val angleGlesPath = RendererRegistry.getRendererLibraryPath("libGLESv2_angle.so") + + if (!gl4esPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = gl4esPath + } + if (!angleEglPath.isNullOrEmpty()) { + envMap["RALCORE_EGL"] = "libEGL_angle.so" + envMap["RALCORE_EGL_PATH"] = angleEglPath + } + if (!angleGlesPath.isNullOrEmpty()) { + envMap["LIBGL_GLES"] = "libGLESv2_angle.so" + envMap["RALCORE_ANGLE_GLES_PATH"] = angleGlesPath + } + } + + RendererRegistry.ID_MOBILEGLUES -> { + val mobilegluesPath = RendererRegistry.getRendererLibraryPath("libmobileglues.so") + if (!mobilegluesPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = mobilegluesPath + } + } + + RendererRegistry.ID_ZINK -> { + val osMesaPath = RendererRegistry.getRendererLibraryPath("libOSMesa.so") + if (!osMesaPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = osMesaPath + } + } + + else -> { + val fallbackPath = glesPath ?: eglPath + if (!fallbackPath.isNullOrEmpty()) { + envMap["FNA3D_OPENGL_LIBRARY"] = fallbackPath + } + } + } + + if (preloadPaths.isNotEmpty()) { + envMap["RALCORE_PRELOAD_LIBS"] = preloadPaths.joinToString(":") + AppLogger.info(TAG, "RALCORE_PRELOAD_LIBS = ${envMap["RALCORE_PRELOAD_LIBS"]}") + } + + EnvVarsManager.quickSetEnvVars(envMap) + + AppLogger.info(TAG, "Renderer loaded: $normalizedRenderer") + AppLogger.info(TAG, "RALCORE_RENDERER = ${Os.getenv("RALCORE_RENDERER")}") + AppLogger.info(TAG, "RALCORE_EGL = ${Os.getenv("RALCORE_EGL")}") + AppLogger.info(TAG, "LIBGL_GLES = ${Os.getenv("LIBGL_GLES")}") + AppLogger.info(TAG, "FNA3D_OPENGL_LIBRARY = ${Os.getenv("FNA3D_OPENGL_LIBRARY")}") + true } catch (e: Exception) { AppLogger.error(TAG, "Renderer loading failed: ${e.message}") @@ -72,10 +141,19 @@ object RendererLoader { fun getCurrentRenderer(): String { val ralcoreRenderer = Os.getenv("RALCORE_RENDERER") val ralcoreEgl = Os.getenv("RALCORE_EGL") + val fnaLib = Os.getenv("FNA3D_OPENGL_LIBRARY") + return when { - !ralcoreRenderer.isNullOrEmpty() -> ralcoreRenderer - ralcoreEgl?.contains("angle") == true -> "angle" - else -> "native" + ralcoreRenderer == "gl4es" && ralcoreEgl?.contains("angle") == true -> RendererRegistry.ID_GL4ES_ANGLE + ralcoreRenderer == "gl4es" -> RendererRegistry.ID_GL4ES + ralcoreRenderer == "mobileglues" -> RendererRegistry.ID_MOBILEGLUES + ralcoreRenderer == "vulkan_zink" -> RendererRegistry.ID_ZINK + ralcoreRenderer == "angle" -> RendererRegistry.ID_ANGLE + ralcoreEgl?.contains("angle") == true -> RendererRegistry.ID_ANGLE + fnaLib?.contains("gl4es") == true -> RendererRegistry.ID_GL4ES + fnaLib?.contains("mobileglues") == true -> RendererRegistry.ID_MOBILEGLUES + fnaLib?.contains("OSMesa") == true -> RendererRegistry.ID_ZINK + else -> RendererRegistry.ID_NATIVE } } } diff --git a/app/src/main/java/com/app/ralaunch/core/ui/dialog/PatchManagementDialogCompose.kt b/app/src/main/java/com/app/ralaunch/core/ui/dialog/PatchManagementDialogCompose.kt index 94c3f741..04f1bb3d 100644 --- a/app/src/main/java/com/app/ralaunch/core/ui/dialog/PatchManagementDialogCompose.kt +++ b/app/src/main/java/com/app/ralaunch/core/ui/dialog/PatchManagementDialogCompose.kt @@ -42,13 +42,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent import java.io.File -import java.nio.file.Files -import java.nio.file.Paths +import java.io.FileOutputStream -/** - * 补丁管理对话框 - Compose 版本 - * 横屏双栏布局:左侧游戏列表,右侧补丁列表 - */ @Composable fun PatchManagementDialogCompose( onDismiss: () -> Unit @@ -68,19 +63,16 @@ fun PatchManagementDialogCompose( var selectedGameIndex by remember { mutableIntStateOf(-1) } var patches by remember { mutableStateOf>(emptyList()) } - // 加载游戏列表 LaunchedEffect(Unit) { games = gameRepository?.games?.value ?: emptyList() } - // 当选择游戏改变时加载补丁 LaunchedEffect(selectedGame) { patches = selectedGame?.let { game -> patchManager?.getApplicablePatches(game.gameId) ?: emptyList() } ?: emptyList() } - // 文件选择器 val patchFilePicker = rememberLauncherForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> @@ -88,7 +80,6 @@ fun PatchManagementDialogCompose( scope.launch { importPatchFile(context, patchManager, it) { success -> if (success) { - // 刷新补丁列表 selectedGame?.let { game -> patches = patchManager?.getApplicablePatches(game.gameId) ?: emptyList() } @@ -120,7 +111,6 @@ fun PatchManagementDialogCompose( .fillMaxSize() .padding(24.dp) ) { - // 顶部标题栏 Row( modifier = Modifier .fillMaxWidth() @@ -137,7 +127,6 @@ fun PatchManagementDialogCompose( Row { TextButton( onClick = { - // 显示导入说明对话框后打开选择器 patchFilePicker.launch("application/zip") } ) { @@ -156,14 +145,12 @@ fun PatchManagementDialogCompose( } } - // 主内容区域 - 横向双栏 Row( modifier = Modifier .fillMaxSize() .weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - // 左侧:游戏列表 GameListPanel( games = games, selectedIndex = selectedGameIndex, @@ -174,7 +161,6 @@ fun PatchManagementDialogCompose( modifier = Modifier.weight(1f) ) - // 右侧:补丁列表 PatchListPanel( patches = patches, selectedGame = selectedGame, @@ -231,7 +217,7 @@ private fun GameListPanel( itemsIndexed(games) { index, game -> GameSelectableItem( game = game, - isSelected = index == selectedIndex, + isSelected = index == selectedIndex, // FIXED onClick = { onGameSelected(game, index) } ) } @@ -273,7 +259,6 @@ private fun GameSelectableItem( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // 游戏图标 Box( modifier = Modifier .size(40.dp) @@ -281,7 +266,7 @@ private fun GameSelectableItem( .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { - val iconPathFull = game.iconPathFull // Use absolute path + val iconPathFull = game.iconPathFull if (!iconPathFull.isNullOrEmpty() && File(iconPathFull).exists()) { val bitmap = remember(iconPathFull) { BitmapFactory.decodeFile(iconPathFull)?.asImageBitmap() @@ -400,9 +385,8 @@ private fun PatchItem( patchManager: PatchManager?, context: android.content.Context ) { - // Use absolute path for patch config, not relative path val gameAsmPath = remember(selectedGame) { - selectedGame.gameExePathFull?.let { Paths.get(it) } ?: Paths.get(selectedGame.gameExePathRelative) + selectedGame.gameExePathFull?.let { File(it) } ?: File(selectedGame.gameExePathRelative) } var isEnabled by remember(patch, selectedGame) { mutableStateOf(patchManager?.isPatchEnabled(gameAsmPath, patch.manifest.id) ?: false) @@ -474,13 +458,13 @@ private suspend fun importPatchFile( withContext(Dispatchers.IO) { try { TemporaryFileAcquirer().use { tfa -> - val tempPatchPath = tfa.acquireTempFilePath("imported_patch.zip") + val tempPatchFile = tfa.acquireTempFilePath("imported_patch.zip") context.contentResolver.openInputStream(uri)?.use { inputStream -> - Files.newOutputStream(tempPatchPath).use { outputStream -> + FileOutputStream(tempPatchFile).use { outputStream -> StreamUtils.transferTo(inputStream, outputStream) } } - val result = patchManager?.installPatch(tempPatchPath) ?: false + val result = patchManager?.installPatch(tempPatchFile) ?: false withContext(Dispatchers.Main) { if (result) { Toast.makeText(context, R.string.patch_dialog_import_successful, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameActivity.kt b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameActivity.kt index 8c736c29..a9a2244b 100644 --- a/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameActivity.kt +++ b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameActivity.kt @@ -33,11 +33,9 @@ import com.app.ralaunch.core.common.ErrorHandler import com.app.ralaunch.shared.core.model.domain.ThemeMode import com.app.ralaunch.shared.core.platform.AppConstants import org.libsdl.app.SDLActivity +import com.app.ralaunch.core.platform.runtime.BlackBoxLogger +import com.app.ralaunch.feature.game.legacy.GameBoost -/** - * 游戏运行界面 - * 继承 SDLActivity,实现 MVP 的 View 层 - */ class GameActivity : SDLActivity(), GameContract.View { companion object { @@ -55,10 +53,7 @@ class GameActivity : SDLActivity(), GameContract.View { private set @JvmStatic - fun createLaunchIntent( - context: Context, - gameStorageId: String - ): Intent { + fun createLaunchIntent(context: Context, gameStorageId: String): Intent { require(gameStorageId.isNotBlank()) { "gameStorageId must not be blank" } return Intent(context, GameActivity::class.java).apply { putExtra(EXTRA_GAME_STORAGE_ID, gameStorageId) @@ -67,12 +62,8 @@ class GameActivity : SDLActivity(), GameContract.View { @JvmStatic fun createLaunchIntent( - context: Context, - gameExePath: String, - gameArgs: Array, - gameId: String?, - gameRendererOverride: String?, - gameEnvVars: Map = emptyMap() + context: Context, gameExePath: String, gameArgs: Array, + gameId: String?, gameRendererOverride: String?, gameEnvVars: Map = emptyMap() ): Intent { require(gameExePath.isNotBlank()) { "gameExePath must not be blank" } return Intent(context, GameActivity::class.java).apply { @@ -85,78 +76,44 @@ class GameActivity : SDLActivity(), GameContract.View { } @JvmStatic - fun launch( - context: Context, - gameStorageId: String - ) { - context.startActivity( - createLaunchIntent( - context = context, - gameStorageId = gameStorageId - ) - ) + fun launch(context: Context, gameStorageId: String) { + context.startActivity(createLaunchIntent(context, gameStorageId)) (context as? Activity)?.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } @JvmStatic fun launch( - context: Context, - gameExePath: String, - gameArgs: Array, - gameId: String?, - gameRendererOverride: String?, - gameEnvVars: Map = emptyMap() + context: Context, gameExePath: String, gameArgs: Array, + gameId: String?, gameRendererOverride: String?, gameEnvVars: Map = emptyMap() ) { - context.startActivity( - createLaunchIntent( - context = context, - gameExePath = gameExePath, - gameArgs = gameArgs, - gameId = gameId, - gameRendererOverride = gameRendererOverride, - gameEnvVars = gameEnvVars - ) - ) + context.startActivity(createLaunchIntent(context, gameExePath, gameArgs, gameId, gameRendererOverride, gameEnvVars)) (context as? Activity)?.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } - // ==================== 静态方法供 JNI/其他类调用 ==================== - @JvmStatic - fun sendTextToGame(text: String) { - GameImeHelper.sendTextToGame(text) - } + fun sendTextToGame(text: String) { GameImeHelper.sendTextToGame(text) } @JvmStatic - fun sendBackspace() { - GameImeHelper.sendBackspaceToGame() - } + fun sendBackspace() { GameImeHelper.sendBackspaceToGame() } @JvmStatic - fun enableSDLTextInputForIME() { - GameImeHelper.enableSDLTextInputForIME() - } + fun enableSDLTextInputForIME() { GameImeHelper.enableSDLTextInputForIME() } @JvmStatic - fun disableSDLTextInput() { - GameImeHelper.disableSDLTextInput() - } + fun disableSDLTextInput() { GameImeHelper.disableSDLTextInput() } @JvmStatic fun onGameExitWithMessage(exitCode: Int, errorMessage: String?) { instance?.presenter?.onGameExit(exitCode, errorMessage) } - // Touch bridge native methods @JvmStatic fun nativeSetTouchDataBridge(count: Int, x: FloatArray, y: FloatArray, screenWidth: Int, screenHeight: Int) { nativeSetTouchData(count, x, y, screenWidth, screenHeight) } @JvmStatic - fun nativeClearTouchDataBridge() { - nativeClearTouchData() - } + fun nativeClearTouchDataBridge() { nativeClearTouchData() } @JvmStatic private external fun nativeSetTouchData(count: Int, x: FloatArray, y: FloatArray, screenWidth: Int, screenHeight: Int) @@ -165,16 +122,14 @@ class GameActivity : SDLActivity(), GameContract.View { private external fun nativeClearTouchData() } - // MVP private val presenter: GamePresenter = GamePresenter() - - // 管理器 private var fullscreenManager: GameFullscreenManager? = null private val virtualControlsManager = GameVirtualControlsManager() private val touchBridge = GameTouchBridge() private var lastRequestedRefreshRate: Float = 0f - // ==================== 生命周期 ==================== + private val uiHandler = Handler(Looper.getMainLooper()) + private val hideUiRunnable = Runnable { hideNavigationBarDefinitively() } override fun attachBaseContext(newBase: Context) { super.attachBaseContext(LocaleManager.applyLanguage(newBase)) @@ -188,27 +143,64 @@ class GameActivity : SDLActivity(), GameContract.View { instance = this presenter.attach(this) - // 初始化日志系统 (游戏进程独立于主进程) + BlackBoxLogger.startRecording(this) + + val prefs = getSharedPreferences("RAL_Settings", Context.MODE_PRIVATE) + val selectedRenderer = prefs.getString("CHOOSEN_RENDERER", "native") ?: "native" + + GameBoost.ignite(this) + initializeLogger() - initializeErrorHandler() forceLandscapeOrientation() initializeFullscreenManager() - initializeVirtualControls() requestHighRefreshRate("onCreate") AppLogger.info(TAG, "GameActivity onCreate completed") } + + @Suppress("DEPRECATION") + private fun hideNavigationBarDefinitively() { + try { + window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + val flags = ( + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or android.view.View.SYSTEM_UI_FLAG_FULLSCREEN + or android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + + window.decorView.systemUiVisibility = flags + + // Build.VERSION_CODES.O is Android 8.0 (API 26). + // This leaves modern devices alone and only tortures the old ones! + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + uiHandler.removeCallbacks(hideUiRunnable) + uiHandler.postDelayed(hideUiRunnable, 300) + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to hide navigation bar: ${e.message}") + } + } + + override fun onResume() { + super.onResume() + hideNavigationBarDefinitively() + + if (mLayout != null) { + initializeVirtualControls() + } + } private fun initializeLogger() { try { val logDir = java.io.File(getExternalFilesDir(null), AppConstants.Dirs.LOGS) AppLogger.init(logDir) - AppLogger.info(TAG, "=== GameActivity Process Started ===") - AppLogger.info(TAG, "Game process PID: ${android.os.Process.myPid()}") - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize logger in game process", e) - } + } catch (e: Exception) {} } private fun applyThemeMode() { @@ -222,18 +214,11 @@ class GameActivity : SDLActivity(), GameContract.View { } private fun initializeErrorHandler() { - try { - ErrorHandler.setCurrentActivity(this) - } catch (e: Exception) { - AppLogger.error(TAG, "设置 ErrorHandler 失败: ${e.message}") - } + try { ErrorHandler.setCurrentActivity(this) } catch (e: Exception) {} } private fun forceLandscapeOrientation() { - try { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } catch (_: Exception) { - } + try { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } catch (_: Exception) {} } private fun initializeFullscreenManager() { @@ -241,6 +226,7 @@ class GameActivity : SDLActivity(), GameContract.View { enableFullscreen() configureIME() } + hideNavigationBarDefinitively() } private fun initializeVirtualControls() { @@ -249,28 +235,18 @@ class GameActivity : SDLActivity(), GameContract.View { sdlLayout = mLayout as ViewGroup, sdlSurface = mSurface, disableSDLTextInput = { disableSDLTextInput() }, - onExitGame = { exitGame() } + onExitGame = { finish() } ) } - private fun exitGame() { - // 通过 Presenter 正常退出游戏 - finish() - } - override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) fullscreenManager?.onWindowFocusChanged(hasFocus) if (hasFocus) { - requestHighRefreshRate("onWindowFocusChanged") + hideNavigationBarDefinitively() } } - override fun onResume() { - super.onResume() - requestHighRefreshRate("onResume") - } - @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) @@ -281,78 +257,58 @@ class GameActivity : SDLActivity(), GameContract.View { @SuppressLint("MissingSuperCall") @Deprecated("Deprecated in Java") - override fun onBackPressed() { - // 按下返回键不做任何操作,由悬浮菜单控制 - // 用户可以通过悬浮菜单退出游戏 - } + override fun onBackPressed() {} override fun onDestroy() { - Log.d(TAG, "GameActivity.onDestroy() called") + BlackBoxLogger.stopRecording() - virtualControlsManager.stop() - presenter.detach() - - super.onDestroy() - - // [重要] .NET runtime 不支持多次初始化 - // GameActivity 运行在独立进程,终止不影响主应用 - Handler(Looper.getMainLooper()).postDelayed({ - Log.d(TAG, "Terminating game process to ensure clean .NET runtime state") - Process.killProcess(Process.myPid()) - System.exit(0) - }, 100) + try { + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DEFAULT) + } catch (t: Throwable) {} + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + uiHandler.removeCallbacks(hideUiRunnable) } - // ==================== SDL 重写 ==================== - + virtualControlsManager.stop() + presenter.detach() + + super.onDestroy() + } + override fun setOrientationBis(w: Int, h: Int, resizable: Boolean, hint: String?) { super.setOrientationBis(w, h, resizable, "LandscapeLeft LandscapeRight") } override fun getMainFunction(): String = "SDL_main" - override fun Main(args: Array?) { - presenter.launchGame() - } + override fun Main(args: Array?) { presenter.launchGame() } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - // 返回键处理:切换悬浮球可见性 - if (event.keyCode == KeyEvent.KEYCODE_BACK) { - if (event.action == KeyEvent.ACTION_DOWN) { - virtualControlsManager.toggleFloatingBall() - } - return true // 拦截返回键,不退出游戏 + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN) { + virtualControlsManager.toggleFloatingBall() + return true } - return super.dispatchKeyEvent(event) } override fun dispatchTouchEvent(event: MotionEvent): Boolean { val result = super.dispatchTouchEvent(event) touchBridge.handleMotionEvent(event, resources) + + if (event.action == MotionEvent.ACTION_DOWN) { + hideNavigationBarDefinitively() + } + return result } - // ==================== 公开方法 ==================== - - fun toggleVirtualControls() { - virtualControlsManager.toggle(this) - } - - fun setVirtualControlsVisible(visible: Boolean) { - virtualControlsManager.setVisible(visible) - } - - // ==================== GameContract.View 实现 ==================== - - override fun showToast(message: String) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - } - - override fun showError(title: String, message: String) { - ErrorHandler.showWarning(title, message) - } + fun toggleVirtualControls() { virtualControlsManager.toggle(this) } + fun setVirtualControlsVisible(visible: Boolean) { virtualControlsManager.setVisible(visible) } + override fun showToast(message: String) { Toast.makeText(this, message, Toast.LENGTH_LONG).show() } + override fun showError(title: String, message: String) { ErrorHandler.showWarning(title, message) } + override fun showCrashReport( stackTrace: String, errorDetails: String, @@ -371,25 +327,12 @@ class GameActivity : SDLActivity(), GameContract.View { } override fun getStringRes(resId: Int): String = getString(resId) - override fun getStringRes(resId: Int, vararg args: Any): String = getString(resId, *args) - - override fun runOnMainThread(action: () -> Unit) { - runOnUiThread { action() } - } - - override fun finishActivity() { - finish() - } - + override fun runOnMainThread(action: () -> Unit) { runOnUiThread { action() } } + override fun finishActivity() { finish() } override fun getActivityIntent(): Intent = intent - override fun getAppVersionName(): String? { - return try { - packageManager.getPackageInfo(packageName, 0).versionName - } catch (e: Exception) { - null - } + return try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: Exception) { null } } private fun requestHighRefreshRate(caller: String) { diff --git a/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameBoost.kt b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameBoost.kt new file mode 100644 index 00000000..805bb624 --- /dev/null +++ b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GameBoost.kt @@ -0,0 +1,96 @@ +package com.app.ralaunch.feature.game.legacy + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import android.os.Process +import android.system.Os +import android.util.Log +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.* +import java.util.stream.Collectors + +object GameBoost { + + private const val TAG = "GameBoost" + + fun ignite(context: Context) { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + am.getMemoryInfo(memInfo) + + val totalRamGB = memInfo.totalMem / (1024.0 * 1024.0 * 1024.0) + + optimizeFileSystemNio(context) + + if (totalRamGB < 4.0) { + applyLowMemoryGc() + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { + applyAndroid7LegacyTweaks() + } + + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_DISPLAY) + Os.setenv("DOTNET_TieredCompilation", "1", true) + Os.setenv("DOTNET_ReadyToRun", "1", true) + Os.setenv("mesa_glthread", "true", true) + Os.setenv("vblank_mode", "0", true) + Os.setenv("SDL_JOYSTICK_DISABLE", "1", true) + Os.setenv("SDL_HAPTIC_DISABLE", "1", true) + } catch (t: Throwable) {} + } + + private fun optimizeFileSystemNio(context: Context) { + try { + val gameDir = context.getExternalFilesDir(null)?.toPath() ?: return + if (Files.exists(gameDir)) { + Files.walk(gameDir, 2).use { stream -> + val mods = stream.filter { it.toString().endsWith(".tmod") }.collect(Collectors.toList()) + mods.forEach { path -> + try { + FileChannel.open(path, StandardOpenOption.READ).use { ch -> + val buf = ByteBuffer.allocateDirect(4096) + ch.read(buf) + } + } catch (e: Exception) {} + } + } + } + } catch (t: Throwable) { + Log.e(TAG, "NIO error", t) + } + } + + private fun applyAndroid7LegacyTweaks() { + try { + Os.setenv("SDL_AUDIODRIVER", "android", true) + Os.setenv("SDL_AUDIO_SAMPLES", "512", true) + Os.setenv("FAUDIO_FMT_WBUFFER", "1", true) + Os.setenv("FNA_AUDIO_SAMPLE_RATE", "44100", true) + Os.setenv("ALSOFT_REQCHANNELS", "2", true) + Os.setenv("ALSOFT_REQSAMPLERATE", "44100", true) + Os.setenv("SDL_AUDIO_FORMAT", "s16", true) + Os.setenv("FNA_AUDIO_DISABLE_FLOAT", "1", true) + Os.setenv("SDL_VIDEO_ALLOW_SCREENSAVER", "0", true) + Os.setenv("SDL_HINT_RENDER_LOGICAL_SIZE_MODE", "letterbox", true) + Os.setenv("FNA_GRAPHICS_ENABLE_HIGHDPI", "1", true) + Os.setenv("SDL_ANDROID_TRAP_BACK_BUTTON", "1", true) + Os.setenv("SDL_ANDROID_BLOCK_ON_PAUSE", "0", true) + Os.setenv("SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS", "0", true) + } catch (t: Throwable) { + Log.e(TAG, "Legacy tweak error", t) + } + } + + private fun applyLowMemoryGc() { + try { + Os.setenv("DOTNET_gcServer", "0", true) + Os.setenv("DOTNET_GCConcurrent", "1", true) + Os.setenv("MONO_GC_PARAMS", "nursery-size=16m,soft-heap-limit=1024m,evacuation-threshold=60", true) + Os.setenv("MONO_THREADS_PER_CPU", "30", true) + } catch (t: Throwable) {} + } +} diff --git a/app/src/main/java/com/app/ralaunch/feature/game/legacy/GamePresenter.kt b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GamePresenter.kt index 1b57048c..d562ae46 100644 --- a/app/src/main/java/com/app/ralaunch/feature/game/legacy/GamePresenter.kt +++ b/app/src/main/java/com/app/ralaunch/feature/game/legacy/GamePresenter.kt @@ -1,6 +1,8 @@ + package com.app.ralaunch.feature.game.legacy import android.os.Build +import android.os.Build.VERSION_CODES // ADDED IMPORT import com.app.ralaunch.R import com.app.ralaunch.core.platform.runtime.GameLauncher import com.app.ralaunch.feature.patch.data.Patch @@ -16,10 +18,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** - * 游戏界面 Presenter - * 处理游戏启动和崩溃报告相关的业务逻辑 - */ class GamePresenter : GameContract.Presenter { companion object { @@ -42,7 +40,6 @@ class GamePresenter : GameContract.Presenter { override fun launchGame(): Int { val view = this.view ?: return -1 - // 重置 GameLauncher 初始化状态,确保每次启动都重新初始化 GameLauncher.resetInitializationState() return try { @@ -119,8 +116,9 @@ class GamePresenter : GameContract.Presenter { } catch (_: Exception) { null } + val enabledPatches = patchManager - ?.getApplicableAndEnabledPatches(game.gameId, assemblyFile.toPath()) + ?.getApplicableAndEnabledPatches(game.gameId, assemblyFile) ?: emptyList() return launchAssembly( @@ -158,8 +156,9 @@ class GamePresenter : GameContract.Presenter { } catch (_: Exception) { null } + val enabledPatches = if (gameId != null) { - patchManager?.getApplicableAndEnabledPatches(gameId, assemblyFile.toPath()) ?: emptyList() + patchManager?.getApplicableAndEnabledPatches(gameId, assemblyFile) ?: emptyList() } else { emptyList() } @@ -208,7 +207,7 @@ class GamePresenter : GameContract.Presenter { @Suppress("UNCHECKED_CAST", "DEPRECATION") private fun parseGameEnvVars(intent: android.content.Intent): Map { - val rawMap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val rawMap = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { intent.getSerializableExtra(GameActivity.EXTRA_GAME_ENV_VARS, HashMap::class.java) } else { intent.getSerializableExtra(GameActivity.EXTRA_GAME_ENV_VARS) @@ -335,23 +334,15 @@ class GamePresenter : GameContract.Presenter { private fun getRecentLogcatLogs(view: GameContract.View): String? { return try { - // 关键日志标签列表 - 用于捕获所有重要的运行时信息 val importantTags = listOf( - // 核心组件 "GameLauncher", "GamePresenter", "RuntimeLibLoader", "RuntimeLibraryLoader", - // 渲染器 "RendererEnvironmentConfigurator", "RendererRegistry", "RendererLoader", - // .NET 运行时 "DotNetHost", "DotNetLauncher", "CoreCLR", "MonoGame", - // SDL 和音频 "SDL", "SDL_android", "SDLSurface", "FNA3D", "OpenAL", "FMOD", - // 系统层 "libc", "linker", "art", "dalvikvm", - // 错误相关 "FATAL", "AndroidRuntime", "System.err" ) - // 构建 logcat 过滤器 val tagFilters = importantTags.flatMap { listOf("$it:V") }.toTypedArray() val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "-t", "500", "*:S") + tagFilters @@ -360,10 +351,8 @@ class GamePresenter : GameContract.Presenter { val allLogs = reader.readLines() process.destroy() - // 过滤和整理日志 val filteredLogs = allLogs .filter { line -> - // 过滤掉空行和无关的系统日志 line.isNotBlank() && !line.contains("GC_") && !line.contains("Choreographer") && @@ -372,7 +361,6 @@ class GamePresenter : GameContract.Presenter { .takeLast(MAX_LOG_LINES) .joinToString("\n") - // 如果没有找到有用的日志,尝试获取所有错误级别的日志 if (filteredLogs.isEmpty()) { return getErrorLevelLogs() } @@ -389,9 +377,6 @@ class GamePresenter : GameContract.Presenter { } } - /** - * 获取错误级别的日志(备用方案) - */ private fun getErrorLevelLogs(): String? { return try { val process = Runtime.getRuntime().exec( diff --git a/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt b/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt index abf664ff..49e801d5 100644 --- a/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt +++ b/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt @@ -2,6 +2,7 @@ package com.app.ralaunch.feature.init import android.content.Intent import android.content.SharedPreferences +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -9,7 +10,6 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.* import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -17,7 +17,6 @@ import com.app.ralaunch.R import com.app.ralaunch.core.common.PermissionManager import com.app.ralaunch.shared.core.platform.AppConstants import com.app.ralaunch.shared.core.model.ui.ComponentState -import com.app.ralaunch.shared.core.model.ui.InitUiState import com.app.ralaunch.shared.core.theme.RaLaunchTheme import com.app.ralaunch.feature.main.MainActivityCompose import com.app.ralaunch.core.common.util.AppLogger @@ -29,28 +28,30 @@ import java.io.File import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean -/** - * 初始化 Activity - * 独立的横屏初始化界面,完成后跳转主界面 - */ class InitializationActivity : ComponentActivity() { private lateinit var permissionManager: PermissionManager private lateinit var prefs: SharedPreferences - + private val executor = Executors.newSingleThreadExecutor() private val mainHandler = Handler(Looper.getMainLooper()) private val isExtracting = AtomicBoolean(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + + // ✅ FIX Android 7 crash (EdgeToEdge chỉ bật từ API 26+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + enableEdgeToEdge() + } + hideSystemUI() prefs = getSharedPreferences(AppConstants.PREFS_NAME, 0) - // 检查是否已完成初始化 - val extracted = prefs.getBoolean(AppConstants.InitKeys.COMPONENTS_EXTRACTED, false) + val extracted = + prefs.getBoolean(AppConstants.InitKeys.COMPONENTS_EXTRACTED, false) + if (extracted) { navigateToMain() return @@ -64,7 +65,7 @@ class InitializationActivity : ComponentActivity() { permissionManager = permissionManager, onComplete = { navigateToMain() }, onExit = { finish() }, - onExtract = { components, onUpdate -> + onExtract = { components, onUpdate -> startExtraction(components, onUpdate) }, prefs = prefs, @@ -74,11 +75,15 @@ class InitializationActivity : ComponentActivity() { } } + // ✅ FIX Android 7 crash (WindowInsets chỉ chạy API 26+) private fun hideSystemUI() { - WindowCompat.setDecorFitsSystemWindows(window, false) - WindowInsetsControllerCompat(window, window.decorView).let { controller -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = + WindowInsetsControllerCompat(window, window.decorView) controller.hide(WindowInsetsCompat.Type.systemBars()) - controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } @@ -97,14 +102,31 @@ class InitializationActivity : ComponentActivity() { try { extractAll(components, onUpdate) mainHandler.post { - prefs.edit().putBoolean(AppConstants.InitKeys.COMPONENTS_EXTRACTED, true).apply() - Toast.makeText(this, getString(R.string.init_dotnet_install_success), Toast.LENGTH_SHORT).show() - mainHandler.postDelayed({ navigateToMain() }, 1000) + prefs.edit() + .putBoolean( + AppConstants.InitKeys.COMPONENTS_EXTRACTED, + true + ).apply() + + Toast.makeText( + this, + getString(R.string.init_dotnet_install_success), + Toast.LENGTH_SHORT + ).show() + + mainHandler.postDelayed( + { navigateToMain() }, + 1000 + ) } } catch (e: Exception) { AppLogger.error("InitActivity", "Extraction failed", e) mainHandler.post { - ErrorHandler.handleError(e.message ?: getString(R.string.error_message_default), e) + ErrorHandler.handleError( + e.message + ?: getString(R.string.error_message_default), + e + ) } } finally { isExtracting.set(false) @@ -119,68 +141,135 @@ class InitializationActivity : ComponentActivity() { val filesDir = filesDir components.forEachIndexed { index, component -> + if (!component.needsExtraction) { mainHandler.post { - onUpdate(index, 100, true, getString(R.string.init_no_extraction_needed)) + onUpdate( + index, + 100, + true, + getString(R.string.init_no_extraction_needed) + ) } return@forEachIndexed } mainHandler.post { - onUpdate(index, 10, false, getString(R.string.init_preparing_file)) + onUpdate( + index, + 10, + false, + getString(R.string.init_preparing_file) + ) } - val tempFile = File(cacheDir, "temp_${component.fileName}") - ArchiveExtractor.copyAssetToFile(this, component.fileName, tempFile) + val tempFile = + File(cacheDir, "temp_${component.fileName}") + + ArchiveExtractor.copyAssetToFile( + this, + component.fileName, + tempFile + ) mainHandler.post { - onUpdate(index, 30, false, getString(R.string.init_extracting)) + onUpdate( + index, + 30, + false, + getString(R.string.init_extracting) + ) } - val outputDir = File(filesDir, component.name).apply { mkdirs() } - val stripPrefix = "${component.name.lowercase()}/" + val outputDir = + File(filesDir, component.name).apply { mkdirs() } - val callback = ArchiveExtractor.ProgressCallback { files, _ -> - val progress = (40 + minOf(files / 10, 50)) - mainHandler.post { - onUpdate(index, progress, false, getString(R.string.init_extracting_files, files)) + val stripPrefix = + "${component.name.lowercase()}/" + + val callback = + ArchiveExtractor.ProgressCallback { files, _ -> + val progress = + (40 + minOf(files / 10, 50)) + mainHandler.post { + onUpdate( + index, + progress, + false, + getString( + R.string.init_extracting_files, + files + ) + ) + } } - } when { component.fileName.endsWith(".tar.xz") -> - ArchiveExtractor.extractTarXz(tempFile, outputDir, stripPrefix, callback) + ArchiveExtractor.extractTarXz( + tempFile, + outputDir, + stripPrefix, + callback + ) + component.fileName.endsWith(".tar.gz") -> - ArchiveExtractor.extractTarGz(tempFile, outputDir, stripPrefix, callback) + ArchiveExtractor.extractTarGz( + tempFile, + outputDir, + stripPrefix, + callback + ) + else -> - ArchiveExtractor.extractTar(tempFile, outputDir, stripPrefix, callback) + ArchiveExtractor.extractTar( + tempFile, + outputDir, + stripPrefix, + callback + ) } tempFile.delete() + mainHandler.post { - onUpdate(index, 100, true, getString(R.string.init_complete)) + onUpdate( + index, + 100, + true, + getString(R.string.init_complete) + ) } } - - // 后台解压 runtime_libs(如果存在且未解压) + extractRuntimeLibsIfNeeded() } - + private fun extractRuntimeLibsIfNeeded() { try { - val hasRuntimeLibs = assets.list("")?.contains("runtime_libs.tar.xz") == true - if (hasRuntimeLibs && !RuntimeLibraryLoader.isExtracted(this)) { + val hasRuntimeLibs = + assets.list("")?.contains("runtime_libs.tar.xz") == true + + if (hasRuntimeLibs && + !RuntimeLibraryLoader.isExtracted(this) + ) { runBlocking { - RuntimeLibraryLoader.extractRuntimeLibs(this@InitializationActivity) { _, _ -> } + RuntimeLibraryLoader.extractRuntimeLibs( + this@InitializationActivity + ) { _, _ -> } } } } catch (e: Exception) { - AppLogger.error("InitActivity", "Failed to extract runtime libs: ${e.message}") + AppLogger.error( + "InitActivity", + "Failed to extract runtime libs: ${e.message}" + ) } } override fun onDestroy() { super.onDestroy() - if (!executor.isShutdown) executor.shutdownNow() + if (!executor.isShutdown) + executor.shutdownNow() } } diff --git a/app/src/main/java/com/app/ralaunch/feature/patch/data/Patch.kt b/app/src/main/java/com/app/ralaunch/feature/patch/data/Patch.kt index 991af9e8..33024c96 100644 --- a/app/src/main/java/com/app/ralaunch/feature/patch/data/Patch.kt +++ b/app/src/main/java/com/app/ralaunch/feature/patch/data/Patch.kt @@ -1,38 +1,42 @@ package com.app.ralaunch.feature.patch.data import android.util.Log -import java.nio.file.Files -import java.nio.file.Path +import java.io.File /** * 补丁数据类 */ data class Patch( - val patchPath: Path, + val patchDir: File, val manifest: PatchManifest ) { - fun getEntryAssemblyAbsolutePath(): Path { - return patchPath.resolve(manifest.entryAssemblyFile).toAbsolutePath().normalize() + fun getEntryAssemblyAbsolutePath(): File { + // CHANGED: Use absoluteFile instead of canonicalFile to prevent IOException during launch flow + return File(patchDir, manifest.entryAssemblyFile).absoluteFile } companion object { private const val TAG = "Patch" @JvmStatic - fun fromPatchPath(patchPath: Path): Patch? { - val normalizedPath = patchPath.normalize() - if (!Files.exists(normalizedPath) || !Files.isDirectory(normalizedPath)) { - Log.w(TAG, "fromPatchPath: Patch path does not exist or is not a directory: $normalizedPath") + fun fromPatchPath(patchDir: File): Patch? { + // CHANGED: Use absoluteFile to prevent IOException during patch discovery gracefully + val normalizedDir = patchDir.absoluteFile + + if (!normalizedDir.exists() || !normalizedDir.isDirectory) { + Log.w(TAG, "fromPatchPath: Patch path does not exist or is not a directory: $normalizedDir") return null } - val manifest = PatchManifest.fromJson(normalizedPath.resolve(PatchManifest.MANIFEST_FILE_NAME)) + val manifest = PatchManifest.fromJson( + File(normalizedDir, PatchManifest.MANIFEST_FILE_NAME) + ) if (manifest == null) { - Log.w(TAG, "fromPatchPath: Failed to load manifest from path: $normalizedPath") + Log.w(TAG, "fromPatchPath: Failed to load manifest from path: $normalizedDir") return null } - return Patch(normalizedPath, manifest) + return Patch(normalizedDir, manifest) } } } diff --git a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManager.kt b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManager.kt index 126f8609..b948eb21 100644 --- a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManager.kt +++ b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManager.kt @@ -7,51 +7,42 @@ import com.app.ralaunch.core.platform.install.extractors.ExtractorCollection import com.app.ralaunch.core.common.util.FileUtils import com.app.ralaunch.core.common.util.TemporaryFileAcquirer import org.koin.java.KoinJavaComponent +import java.io.File import java.io.IOException -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.Objects -import java.util.stream.Collectors - -/** - * 补丁管理器 - */ + class PatchManager @JvmOverloads constructor( customStoragePath: String? = null, installPatchesImmediately: Boolean = false ) { - private val patchStoragePath: Path - private val configFilePath: Path - private var config: PatchManagerConfig + private var patchStorageDir: File? = null + private var configFile: File? = null + private var config: PatchManagerConfig = PatchManagerConfig() init { - patchStoragePath = getDefaultPatchStorageDirectories(customStoragePath) - if (!Files.isDirectory(patchStoragePath) || !Files.exists(patchStoragePath)) { - Files.deleteIfExists(patchStoragePath) - Files.createDirectories(patchStoragePath) - } - configFilePath = patchStoragePath.resolve(PatchManagerConfig.CONFIG_FILE_NAME) - config = loadConfig() + try { + val dir = getDefaultPatchStorageDirectories(customStoragePath) + patchStorageDir = dir + + if (!dir.exists() || !dir.isDirectory) { + dir.deleteRecursively() + dir.mkdirs() + } + + val cfgFile = File(dir, PatchManagerConfig.CONFIG_FILE_NAME) + configFile = cfgFile + config = loadConfig(cfgFile) - // 清理旧的共享 DLL 文件 (MonoMod/Harmony 现在在游戏目录中按版本管理) - cleanLegacySharedDlls() + cleanLegacySharedDlls(dir) - // 如果指定立即安装,则在当前线程安装补丁(用于向后兼容) - if (installPatchesImmediately) { - installBuiltInPatches(this) + if (installPatchesImmediately) { + installBuiltInPatches(this) + } + } catch (e: Exception) { + Log.e(TAG, "FATAL ERROR during PatchManager initialization: ${e.message}", e) } } - //region Patch Querying - - /** - * Returns all patches that are applicable to the specified game and are enabled for - * the provided game assembly path. Results are sorted by priority in descending order. - */ - fun getApplicableAndEnabledPatches(gameId: String, gameAsmPath: Path): ArrayList { - val installedPatches = installedPatches - + fun getApplicableAndEnabledPatches(gameId: String, gameAsmPath: File): ArrayList { return installedPatches .filter { isPatchApplicableToGame(it, gameId) } .filter { config.isPatchEnabled(gameAsmPath, it.manifest.id) } @@ -59,9 +50,6 @@ class PatchManager @JvmOverloads constructor( .toCollection(ArrayList()) } - /** - * Returns all patches that are applicable to the specified game, regardless of enabled status. - */ fun getApplicablePatches(gameId: String): ArrayList { return installedPatches .filter { isPatchApplicableToGame(it, gameId) } @@ -75,45 +63,32 @@ class PatchManager @JvmOverloads constructor( return targetGames.contains("*") || targetGames.contains(gameId) } - /** - * Returns all patches that are enabled for the specified game assembly path. - */ - fun getEnabledPatches(gameAsmPath: Path): ArrayList { + fun getEnabledPatches(gameAsmPath: File): ArrayList { return installedPatches .filter { config.isPatchEnabled(gameAsmPath, it.manifest.id) } .sortedByDescending { it.manifest.priority } .toCollection(ArrayList()) } - /** - * Get IDs of all patches that are enabled for the specified game assembly path. - */ - fun getEnabledPatchIds(gameAsmPath: Path): ArrayList { + fun getEnabledPatchIds(gameAsmPath: File): ArrayList { return config.getEnabledPatchIds(gameAsmPath) } - /** - * Scans the patch storage directory and returns all currently installed (valid) patches. - */ val installedPatches: ArrayList get() { + val dir = patchStorageDir ?: return ArrayList() return try { - Files.list(patchStoragePath).use { pathsStream -> - pathsStream - .filter { Files.isDirectory(it) } - .map { Patch.fromPatchPath(it) } - .filter { it != null } - .map { it!! } - .collect(Collectors.toCollection(::ArrayList)) - } - } catch (e: IOException) { - throw RuntimeException(e) + dir.listFiles() + ?.filter { it.isDirectory } + ?.mapNotNull { Patch.fromPatchPath(it) } + ?.toCollection(ArrayList()) + ?: ArrayList() + } catch (e: Exception) { + Log.e(TAG, "Error reading installed patches: ${e.message}") + ArrayList() } } - /** - * Returns patches from the installed patches that match the provided IDs. - */ fun getPatchesByIds(patchIds: List): ArrayList { return installedPatches .filter { patchIds.contains(it.manifest.id) } @@ -121,114 +96,100 @@ class PatchManager @JvmOverloads constructor( .toCollection(ArrayList()) } - //endregion - - //region Patch Installation - - /** - * Install a patch archive (ZIP/7z). - */ - fun installPatch(patchZipPath: Path): Boolean { - if (!Files.exists(patchZipPath) || !Files.isRegularFile(patchZipPath)) { - Log.w(TAG, "补丁安装失败: 补丁文件不存在或不是一个有效的文件, path: $patchZipPath") + fun installPatch(patchZipFile: File): Boolean { + val storageDir = patchStorageDir ?: return false + + if (!patchZipFile.exists() || !patchZipFile.isFile) { + Log.w(TAG, "Patch install failed: file not found: $patchZipFile") return false } - val manifest = PatchManifest.fromZip(patchZipPath) + val manifest = PatchManifest.fromZip(patchZipFile) if (manifest == null) { - Log.w(TAG, "补丁安装失败: 无法读取补丁清单, path: $patchZipPath") + Log.w(TAG, "Patch install failed: cannot read manifest: $patchZipFile") return false } - val patchPath = patchStoragePath.resolve(manifest.id) + val patchDir = File(storageDir, manifest.id) - if (Files.exists(patchPath)) { - Log.i(TAG, "补丁已存在, 将删除原补丁目录,重新安装, patch id: ${manifest.id}") - if (!FileUtils.deleteDirectoryRecursively(patchPath)) { - Log.w(TAG, "删除原补丁目录时发生错误") + if (patchDir.exists()) { + Log.i(TAG, "Patch exists, reinstalling: ${manifest.id}") + if (!FileUtils.deleteDirectoryRecursively(patchDir)) { + Log.w(TAG, "Failed to delete old patch dir") return false } } else { - Log.i(TAG, "正在安装新补丁, patch id: ${manifest.id}") + Log.i(TAG, "Installing new patch: ${manifest.id}") } - Log.i(TAG, "正在解压补丁文件到补丁目录...") - BasicSevenZipExtractor( - patchZipPath, - Paths.get(""), - patchPath, - object : ExtractorCollection.ExtractionListener { - override fun onProgress(message: String, progress: Float, state: HashMap?) {} - override fun onComplete(message: String, state: HashMap?) {} - override fun onError(message: String, ex: Exception?, state: HashMap?) { - throw RuntimeException(message, ex) + Log.i(TAG, "Extracting patch...") + return try { + BasicSevenZipExtractor( + patchZipFile, + File(""), + patchDir, + object : ExtractorCollection.ExtractionListener { + override fun onProgress(message: String, progress: Float, state: HashMap?) {} + override fun onComplete(message: String, state: HashMap?) {} + override fun onError(message: String, ex: Exception?, state: HashMap?) { + Log.e(TAG, "Extraction error: $message", ex) + } } - } - ).extract() - - return true + ).extract() + } catch (e: Exception) { + Log.e(TAG, "Failed to extract patch", e) + false + } } - //endregion - - //region Config Value Setting and Getting - - /** - * Set whether a patch is enabled for a specific game. - */ - fun setPatchEnabled(gameAsmPath: Path, patchId: String, enabled: Boolean) { + fun setPatchEnabled(gameAsmPath: File, patchId: String, enabled: Boolean) { config.setPatchEnabled(gameAsmPath, patchId, enabled) saveConfig() } - /** - * Check if a patch is enabled for a specific game. - */ - fun isPatchEnabled(gameAsmPath: Path, patchId: String): Boolean { + fun isPatchEnabled(gameAsmPath: File, patchId: String): Boolean { return config.isPatchEnabled(gameAsmPath, patchId) } - //endregion - - //region Configuration Management - - private fun loadConfig(): PatchManagerConfig { - val loadedConfig = PatchManagerConfig.fromJson(configFilePath) - return if (loadedConfig == null) { - Log.i(TAG, "配置文件不存在或加载失败,创建新配置") - PatchManagerConfig().also { it.saveToJson(configFilePath) } - } else { - Log.i(TAG, "配置文件加载成功") - loadedConfig + private fun loadConfig(cfgFile: File): PatchManagerConfig { + return try { + val loadedConfig = PatchManagerConfig.fromJson(cfgFile) + if (loadedConfig == null) { + Log.i(TAG, "Config not found, creating new") + val newCfg = PatchManagerConfig() + newCfg.saveToJson(cfgFile) + newCfg + } else { + Log.i(TAG, "Config loaded") + loadedConfig + } + } catch (e: Exception) { + Log.e(TAG, "Error loading config: ${e.message}") + PatchManagerConfig() } } private fun saveConfig() { - if (!config.saveToJson(configFilePath)) { - Log.w(TAG, "保存配置文件失败") + val cfgFile = configFile ?: return + if (!config.saveToJson(cfgFile)) { + Log.w(TAG, "Failed to save config") } } - //endregion - - //region Legacy Cleanup - - private fun cleanLegacySharedDlls() { + private fun cleanLegacySharedDlls(dir: File) { for (dllName in LEGACY_SHARED_DLLS) { - val dllPath = patchStoragePath.resolve(dllName) + val dllFile = File(dir, dllName) try { - if (Files.exists(dllPath)) { - Files.delete(dllPath) - Log.i(TAG, "已清理旧的共享 DLL: $dllName") + if (dllFile.exists()) { + dllFile.delete() + Log.i(TAG, "Cleaned legacy DLL: $dllName") } - } catch (e: IOException) { - Log.w(TAG, "清理 $dllName 失败: ${e.message}") + } catch (e: Exception) { + Log.w(TAG, "Failed to clean $dllName: ${e.message}") } } } - //endregion - companion object { private const val TAG = "PatchManager" private const val IS_DEFAULT_PATCH_STORAGE_DIR_EXTERNAL = true @@ -240,16 +201,17 @@ class PatchManager @JvmOverloads constructor( "Mono.Cecil.dll" ) - @Throws(IOException::class) - private fun getDefaultPatchStorageDirectories(customStoragePath: String?): Path { + private fun getDefaultPatchStorageDirectories(customStoragePath: String?): File { val context: Context = KoinJavaComponent.get(Context::class.java) val baseDir = customStoragePath ?: if (IS_DEFAULT_PATCH_STORAGE_DIR_EXTERNAL) { - Objects.requireNonNull(context.getExternalFilesDir(null))?.absolutePath - ?: context.filesDir.absolutePath + val extDir = context.getExternalFilesDir(null) + extDir?.absolutePath ?: context.filesDir.absolutePath } else { context.filesDir.absolutePath } - return Paths.get(baseDir, PATCH_STORAGE_DIR).normalize() + + // CHANGED: Use absoluteFile instead of canonicalFile to prevent IOException crash on Android 7 + return File(baseDir, PATCH_STORAGE_DIR).absoluteFile } @JvmStatic @@ -259,47 +221,45 @@ class PatchManager @JvmOverloads constructor( @JvmStatic fun installBuiltInPatches(patchManager: PatchManager, forceReinstall: Boolean) { - val context: Context = KoinJavaComponent.get(Context::class.java) - val apkPath = Paths.get(context.applicationInfo.sourceDir) - - TemporaryFileAcquirer().use { tfa -> - val extractedPatches = tfa.acquireTempFilePath("extracted_patches") - - BasicSevenZipExtractor( - apkPath, - Paths.get("assets/patches"), - extractedPatches, - object : ExtractorCollection.ExtractionListener { - override fun onProgress(message: String, progress: Float, state: HashMap?) {} - override fun onComplete(message: String, state: HashMap?) {} - override fun onError(message: String, ex: Exception?, state: HashMap?) { - throw RuntimeException(message, ex) + try { + val context: Context = KoinJavaComponent.get(Context::class.java) + val apkFile = File(context.applicationInfo.sourceDir) + + TemporaryFileAcquirer().use { tfa -> + val extractedPatches = tfa.acquireTempFilePath("extracted_patches") + + BasicSevenZipExtractor( + apkFile, + File("assets/patches"), + extractedPatches, + object : ExtractorCollection.ExtractionListener { + override fun onProgress(message: String, progress: Float, state: HashMap?) {} + override fun onComplete(message: String, state: HashMap?) {} + override fun onError(message: String, ex: Exception?, state: HashMap?) { + Log.e(TAG, "Built-in extraction error: $message", ex) + } } - } - ).extract() - - // 获取已安装补丁的 ID -> 清单映射(用于版本比较) - val installedPatchMap = patchManager.installedPatches - .associateBy { it.manifest.id } - - // 安装缺失或版本更新的内置补丁 - try { - Files.list(extractedPatches).use { pathsStream -> - pathsStream - .filter { Files.isRegularFile(it) && it.toString().endsWith(".zip") } - .forEach { patchZip -> + ).extract() + + val installedPatchMap = patchManager.installedPatches + .associateBy { it.manifest.id } + + extractedPatches.listFiles() + ?.filter { it.isFile && it.name.endsWith(".zip") } + ?.forEach { patchZip -> + try { val manifest = PatchManifest.fromZip(patchZip) - if (manifest == null) return@forEach + ?: return@forEach val installedPatch = installedPatchMap[manifest.id] when { forceReinstall -> { - Log.i(TAG, "正在强制重新安装内置补丁: ${patchZip.fileName} (id: ${manifest.id})") + Log.i(TAG, "Force reinstalling: ${patchZip.name}") patchManager.installPatch(patchZip) } installedPatch == null -> { - Log.i(TAG, "正在安装内置补丁: ${patchZip.fileName} (id: ${manifest.id}, version: ${manifest.version})") + Log.i(TAG, "Installing: ${patchZip.name}") patchManager.installPatch(patchZip) } else -> { @@ -307,46 +267,37 @@ class PatchManager @JvmOverloads constructor( val bundledVersion = manifest.version val cmp = PatchManifest.compareVersions(bundledVersion, installedVersion) if (cmp > 0) { - Log.i(TAG, "检测到补丁更新: ${manifest.id} (${installedVersion} -> ${bundledVersion}),正在自动更新...") + Log.i(TAG, "Updating patch: ${manifest.id} ($installedVersion -> $bundledVersion)") patchManager.installPatch(patchZip) } else { - Log.d(TAG, "补丁已是最新版本,跳过: ${manifest.id} (installed: ${installedVersion}, bundled: ${bundledVersion})") + Log.d(TAG, "Patch up to date: ${manifest.id}") } } } + } catch (e: Exception) { + Log.e(TAG, "Error installing individual patch: ${patchZip.name}", e) } - } - } catch (e: IOException) { - throw RuntimeException(e) + } } + } catch (e: Exception) { + Log.e(TAG, "FATAL ERROR during built-in patch installation: ${e.message}", e) } } - /** - * Construct the environment variable string for startup hooks from a list of patches. - */ @JvmStatic fun constructStartupHooksEnvVar(patches: List): String { - Log.d(TAG, "constructStartupHooksEnvVar: Input patches count = ${patches.size}") - patches.forEachIndexed { index, p -> - Log.d(TAG, " [$index] id=${p.manifest.id}, path=${p.getEntryAssemblyAbsolutePath()}") + return try { + val seenPatchIds = linkedSetOf() + patches + .filter { seenPatchIds.add(it.manifest.id) } + .map { it.getEntryAssemblyAbsolutePath().absolutePath } + .distinct() + .joinToString(":") + } catch (e: Exception) { + Log.e(TAG, "Error constructing startup hooks", e) + "" } - - val seenPatchIds = linkedSetOf() - val result = patches - .filter { p -> - val isNew = seenPatchIds.add(p.manifest.id) - if (!isNew) { - Log.w(TAG, "constructStartupHooksEnvVar: Duplicate patch ID filtered: ${p.manifest.id}") - } - isNew - } - .map { it.getEntryAssemblyAbsolutePath().toString() } - .distinct() - .joinToString(":") - - Log.d(TAG, "constructStartupHooksEnvVar: Result = $result") - return result } } } + diff --git a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManagerConfig.kt b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManagerConfig.kt index 19cffc08..a98a9ef5 100644 --- a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManagerConfig.kt +++ b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManagerConfig.kt @@ -4,108 +4,66 @@ import android.util.Log import com.google.gson.FieldNamingPolicy import com.google.gson.GsonBuilder import com.google.gson.annotations.SerializedName +import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path -/** - * 补丁管理器配置 - */ class PatchManagerConfig { @SerializedName("enabled_patches") var enabledPatches: HashMap> = hashMapOf() - /** - * 存储被禁用的补丁ID(默认所有补丁都是启用的) - */ @SerializedName("disabled_patches") var disabledPatches: HashMap> = hashMapOf() - /** - * Get enabled patch IDs for a specific game - * @param gameAsmPath The game assembly file path - * @return List of enabled patch IDs, or empty list if none - */ - fun getEnabledPatchIds(gameAsmPath: Path): ArrayList { + fun getEnabledPatchIds(gameAsmFile: File): ArrayList { return enabledPatches.getOrDefault( - gameAsmPath.toAbsolutePath().normalize().toString(), + // CHANGED: Use absolutePath to avoid IOException + gameAsmFile.absolutePath, arrayListOf() ) } - /** - * Set enabled patch IDs for a specific game - * @param gameAsmPath The game assembly file path - * @param patchIds List of patch IDs to enable - */ - fun setEnabledPatchIds(gameAsmPath: Path, patchIds: ArrayList) { - enabledPatches[gameAsmPath.toAbsolutePath().normalize().toString()] = patchIds + fun setEnabledPatchIds(gameAsmFile: File, patchIds: ArrayList) { + enabledPatches[gameAsmFile.absolutePath] = patchIds } - /** - * Set whether a patch is enabled for a specific game - * 默认所有补丁都是启用的,这里只记录被禁用的补丁 - * @param gameAsmPath The game assembly file path - * @param patchId The patch ID - * @param enabled true to enable the patch (remove from disabled list), false to disable it (add to disabled list) - */ - fun setPatchEnabled(gameAsmPath: Path, patchId: String, enabled: Boolean) { - val key = gameAsmPath.toAbsolutePath().normalize().toString() + fun setPatchEnabled(gameAsmFile: File, patchId: String, enabled: Boolean) { + val key = gameAsmFile.absolutePath if (enabled) { - // 启用补丁:从禁用列表中移除 disabledPatches[key]?.remove(patchId) - // 同时添加到启用列表(兼容旧逻辑) val patches = enabledPatches.getOrPut(key) { arrayListOf() } if (!patches.contains(patchId)) { patches.add(patchId) } } else { - // 禁用补丁:添加到禁用列表 val disabled = disabledPatches.getOrPut(key) { arrayListOf() } if (!disabled.contains(patchId)) { disabled.add(patchId) } - // 同时从启用列表中移除(兼容旧逻辑) enabledPatches[key]?.remove(patchId) } } - /** - * Check if a patch is enabled for a specific game - * 默认所有补丁都是启用的,只有被显式禁用的才返回false - * @param gameAsmPath The game assembly file path - * @param patchId The patch ID - * @return true if the patch is enabled (not in disabled list), false if explicitly disabled - */ - fun isPatchEnabled(gameAsmPath: Path, patchId: String): Boolean { - val key = gameAsmPath.toAbsolutePath().normalize().toString() - // 检查是否在禁用列表中 + fun isPatchEnabled(gameAsmFile: File, patchId: String): Boolean { + val key = gameAsmFile.absolutePath val disabled = disabledPatches[key] if (disabled != null && disabled.contains(patchId)) { return false } - // 默认启用 return true } - /** - * Save config to JSON file - * @param pathToJson Path to save the config JSON file - * @return true if save succeeds, false otherwise - */ - fun saveToJson(pathToJson: Path): Boolean { - Log.i(TAG, "Save $CONFIG_FILE_NAME, pathToJson: $pathToJson") + fun saveToJson(jsonFile: File): Boolean { + Log.i(TAG, "Save $CONFIG_FILE_NAME, path: ${jsonFile.absolutePath}") return try { - // Ensure parent directory exists - pathToJson.parent?.let { Files.createDirectories(it) } + jsonFile.parentFile?.mkdirs() - FileOutputStream(pathToJson.toFile()).use { stream -> + FileOutputStream(jsonFile).use { stream -> OutputStreamWriter(stream, StandardCharsets.UTF_8).use { writer -> gson.toJson(this, writer) writer.flush() @@ -114,7 +72,7 @@ class PatchManagerConfig { } } } catch (e: Exception) { - Log.w(TAG, "Save configuration file failed: ${Log.getStackTraceString(e)}") + Log.w(TAG, "Save configuration file failed: ${e.message}") false } } @@ -128,28 +86,23 @@ class PatchManagerConfig { .setPrettyPrinting() .create() - /** - * Load config from JSON file - * @param pathToJson Path to the config JSON file - * @return PatchManagerConfig instance, or null if loading fails - */ @JvmStatic - fun fromJson(pathToJson: Path): PatchManagerConfig? { - Log.i(TAG, "加载 $CONFIG_FILE_NAME, pathToJson: $pathToJson") + fun fromJson(jsonFile: File): PatchManagerConfig? { + Log.i(TAG, "Load $CONFIG_FILE_NAME, path: ${jsonFile.absolutePath}") - if (!Files.exists(pathToJson) || !Files.isRegularFile(pathToJson)) { - Log.w(TAG, "路径不存在 $CONFIG_FILE_NAME 文件") + if (!jsonFile.exists() || !jsonFile.isFile) { + Log.w(TAG, "File $CONFIG_FILE_NAME does not exist") return null } return try { - FileInputStream(pathToJson.toFile()).use { stream -> + FileInputStream(jsonFile).use { stream -> InputStreamReader(stream, StandardCharsets.UTF_8).use { reader -> gson.fromJson(reader, PatchManagerConfig::class.java) } } } catch (e: Exception) { - Log.w(TAG, "加载配置文件失败: ${Log.getStackTraceString(e)}") + Log.w(TAG, "Failed to load configuration file: ${e.message}") null } } diff --git a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManifest.kt b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManifest.kt index c9a8343f..aa6d8b38 100644 --- a/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManifest.kt +++ b/app/src/main/java/com/app/ralaunch/feature/patch/data/PatchManifest.kt @@ -8,13 +8,8 @@ import java.io.File import java.io.FileInputStream import java.io.InputStreamReader import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path import java.util.zip.ZipFile -/** - * 补丁清单 - */ data class PatchManifest( @SerializedName("id") var id: String = "", @@ -49,18 +44,11 @@ data class PatchManifest( @SerializedName("dependencies") var dependencies: Dependencies? = null ) { - /** - * 补丁依赖配置 - */ data class Dependencies( - /** - * 补丁特定的库文件列表(相对于补丁目录) - */ @SerializedName("libs") var libs: List? = null ) - // 为了向后兼容,entryAssemblyFile 指向 dllFileName val entryAssemblyFile: String get() = if (!dllFileName.isNullOrEmpty()) dllFileName else "" @@ -81,12 +69,7 @@ data class PatchManifest( .setPrettyPrinting() .create() - @JvmStatic - fun fromZip(pathToZip: Path): PatchManifest? { - - return fromZip(pathToZip.toFile()) - } - + // Xoa ham nhan Path, chi giu ham nhan File @JvmStatic fun fromZip(file: File): PatchManifest? { Log.i(TAG, "load Patch zip, file: ${file.absolutePath}") @@ -109,12 +92,6 @@ data class PatchManifest( } } - /** - * 比较两个版本号字符串。 - * 按 "." 分割后逐段比较数字大小。 - * - * @return 正数表示 v1 > v2,负数表示 v1 < v2,0 表示相等 - */ @JvmStatic fun compareVersions(v1: String, v2: String): Int { val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 } @@ -128,17 +105,18 @@ data class PatchManifest( return 0 } + // Thay Path bang File @JvmStatic - fun fromJson(pathToJson: Path): PatchManifest? { - Log.i(TAG, "load $MANIFEST_FILE_NAME, pathToJson: $pathToJson") + fun fromJson(jsonFile: File): PatchManifest? { + Log.i(TAG, "load $MANIFEST_FILE_NAME, path: ${jsonFile.absolutePath}") - if (!Files.exists(pathToJson) || !Files.isRegularFile(pathToJson)) { - Log.w(TAG, "路径不存在 $MANIFEST_FILE_NAME 文件") + if (!jsonFile.exists() || !jsonFile.isFile) { + Log.w(TAG, "File not found: ${jsonFile.absolutePath}") return null } return try { - FileInputStream(pathToJson.toFile()).use { stream -> + FileInputStream(jsonFile).use { stream -> InputStreamReader(stream, StandardCharsets.UTF_8).use { reader -> gson.fromJson(reader, PatchManifest::class.java) } diff --git a/app/src/main/java/org/libsdl/app/SDLAudioManager.java b/app/src/main/java/org/libsdl/app/SDLAudioManager.java index adb3701f..b75dc35b 100644 --- a/app/src/main/java/org/libsdl/app/SDLAudioManager.java +++ b/app/src/main/java/org/libsdl/app/SDLAudioManager.java @@ -356,24 +356,36 @@ public static void audioWriteFloatBuffer(float[] buffer) { } if (android.os.Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) { - Log.e(TAG, "Attempted to make an incompatible audio call with uninitialized audio! (floating-point output is supported since Android 5.0 Lollipop)"); + Log.e(TAG, "Attempted to make an incompatible audio call! (floating-point output is supported since Android 5.0)"); return; } - for (int i = 0; i < buffer.length;) { - int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING); - if (result > 0) { - i += result; - } else if (result == 0) { - try { - Thread.sleep(1); - } catch(InterruptedException e) { - // Nom nom + // ... Wrap the entire write loop in a protective try-catch ... + try { + for (int i = 0; i < buffer.length;) { + int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(float)"); + return; } - } else { - Log.w(TAG, "SDL audio: error return from write(float)"); - return; } + } catch (IllegalStateException e) { + // ... THE SAVIOR BLOCK ... + // Catches "Unable to retrieve AudioTrack pointer for write()" + // Instead of crashing the whole game, we just drop this specific audio frame. + // The player might hear a tiny skip, but the GAME WILL NOT CRASH! + Log.e(TAG, "CRITICAL: Audio hardware choked! Dropped frame to prevent game crash."); + } catch (Exception e) { + // ... Catch any other random audio hardware failures ... + Log.e(TAG, "Unknown audio hardware failure intercepted: " + e.getMessage()); } } diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foregroud1.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foregroud1.png new file mode 100644 index 00000000..a9346621 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foregroud1.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index ecad06b0..a9346621 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index ecad06b0..a9346621 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index ecad06b0..a9346621 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index ecad06b0..a9346621 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index ecad06b0..a9346621 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 00000000..b3511c6b --- /dev/null +++ b/codemagic.yaml @@ -0,0 +1,79 @@ +# =================================================================== +# CODEMAGIC CI/CD CONFIGURATION FOR RALAUNCHER (MAC MINI M1 FREE) +# =================================================================== +workflows: + android-release-workflow: + name: RotatingartLauncher Release Build + + instance_type: mac_mini_m2 + + environment: + java: 17 + vars: + GIT_LFS_SKIP_SMUDGE: "1" + + # 🚨 THE OVERRIDE: Tell Codemagic NOT to fetch submodules automatically! + # By default, Codemagic runs 'git clone --recurse-submodules'. + # This setting stops that behavior so we can control it safely. + triggering: + events: + - push + + scripts: + - name: 🥷 Safe Clone & Fetch (No LFS, No Submodules allowed yet) + script: | + echo "Overriding default Git behavior to prevent LFS crashes..." + + # 1. Update the main repo code without touching submodules yet + git fetch origin + git checkout $CM_COMMIT + + # 2. Force Git to ignore LFS globally for the rest of this machine's life + git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f" + git config --global filter.lfs.process "git-lfs filter-process --skip" + + echo "Step 3: Now fetching Submodules Safely..." + # Fetch submodules one by one. If one fails due to LFS, ignore it (|| true) + # so it doesn't break the whole build! + git submodule sync + git submodule update --init --recursive --depth 1 || true + + echo "✅ Code and Submodules fetched without crashing!" + + - name: 📦 Inject Heavy LFS Files (Wget) + script: | + echo "Manually downloading our REQUIRED heavy files via direct web link..." + cd app/src/main/assets/ + + rm -f MonoMod.zip + rm -f runtime_libs.tar.xz + rm -f dotnet.tar.xz + + echo "Downloading MonoMod..." + wget -q -O MonoMod.zip "https://media.githubusercontent.com/media/WinterWolfVN/RotatingartLauncher/main/app/src/main/assets/MonoMod.zip" + + echo "Downloading Runtime Libraries..." + wget -q -O runtime_libs.tar.xz "https://media.githubusercontent.com/media/WinterWolfVN/RotatingartLauncher/main/app/src/main/assets/runtime_libs.tar.xz" + + echo "Downloading .NET Core Engine..." + wget -q -O dotnet.tar.xz "https://media.githubusercontent.com/media/WinterWolfVN/RotatingartLauncher/main/app/src/main/assets/dotnet.tar.xz" + + echo "✅ All heavy LFS files successfully injected into assets!" + cd ../../../../ + + - name: 🔑 Generate Dummy Keystore + script: | + echo "Forging a dummy keystore to sign the APK..." + keytool -genkeypair -v -keystore app/dummy.keystore -alias ralaunch -keyalg RSA -keysize 2048 -validity 10000 -storepass 123456 -keypass 123456 -dname "CN=MobileHacker, OU=Dev, O=RaLaunch, C=VN" + echo "✅ Dummy Keystore forged successfully!" + + - name: 🔨 Build Android Release APK + script: | + echo "Starting Gradle Build Process on Mac M1..." + export GRADLE_OPTS="-Dorg.gradle.daemon=true -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4" + chmod +x gradlew + ./gradlew assembleRelease + echo "✅ APK Built Successfully!" + + artifacts: + - app/build/outputs/apk/release/*.apk diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f2ecd70..b84061da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,10 @@ kotlin = "2.0.21" coreKtx = "1.17.0" kotlinx-serialization-json = "1.7.3" kotlinx-coroutines = "1.9.0" + +# --- THÊM DÒNG NÀY (Hỗ trợ Java 8+ API trên Android cũ) --- +desugarJdkLibs = "2.1.4" + # Third-party libraries glide = "4.16.0" konfetti = "2.0.5" @@ -53,6 +57,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } + +# --- THÊM DÒNG NÀY (Thư viện Desugar) --- +desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" } + # Android Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } compose-ui = { group = "androidx.compose.ui", name = "ui" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f40da6eb..705a62c9 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -11,15 +11,25 @@ compose.resources { } kotlin { - androidTarget() + androidTarget { + // Safe way to set compiler options for KMP without experimental warnings + compilations.all { + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-Xexpect-actual-classes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + } + } - jvmToolchain(21) + jvmToolchain(17) sourceSets { val commonMain by getting { kotlin.srcDir("src/commonMain/kotlin") dependencies { - // Compose Multiplatform implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -27,21 +37,17 @@ kotlin { implementation(compose.components.resources) implementation(compose.materialIconsExtended) - // Kotlinx implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) - // Haze (Glassmorphism blur) implementation(libs.haze) implementation(libs.haze.materials) - // Koin DI implementation(libs.koin.core) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) - // DataStore (Common) implementation(libs.datastore.preferences.core) } } @@ -64,6 +70,17 @@ android { compileSdk = 36 defaultConfig { - minSdk = 28 + minSdk = 24 } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } +} + +dependencies { + // Use dot notation for version catalogs alias instead of camelCase + coreLibraryDesugaring(libs.desugar.jdk.libs) } diff --git a/shared/src/androidMain/kotlin/com/app/ralaunch/shared/core/platform/runtime/renderer/RendererRegistry.kt b/shared/src/androidMain/kotlin/com/app/ralaunch/shared/core/platform/runtime/renderer/RendererRegistry.kt index 52ae6a5e..a6ed3c3c 100644 --- a/shared/src/androidMain/kotlin/com/app/ralaunch/shared/core/platform/runtime/renderer/RendererRegistry.kt +++ b/shared/src/androidMain/kotlin/com/app/ralaunch/shared/core/platform/runtime/renderer/RendererRegistry.kt @@ -5,7 +5,6 @@ import android.os.Build import android.os.Environment import com.app.ralaunch.shared.generated.resources.* import java.io.File -import kotlin.io.path.Path import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -60,12 +59,14 @@ actual object RendererRegistry { id = ID_GL4ES_ANGLE, displayName = null, description = null, - eglLibrary = "libEGL_gl4es.so", + eglLibrary = "libEGL_angle.so", glesLibrary = "libGL_gl4es.so", needsPreload = true, - minAndroidVersion = 0 + minAndroidVersion = Build.VERSION_CODES.N ) { _, env -> env["RALCORE_RENDERER"] = "gl4es" + env["RALCORE_EGL"] = "libEGL_angle.so" + env["LIBGL_GLES"] = "libGLESv2_angle.so" env["LIBGL_ES"] = "3" env["LIBGL_MIPMAP"] = "3" env["LIBGL_NORMALIZE"] = "1" @@ -88,8 +89,11 @@ actual object RendererRegistry { env["FNA3D_OPENGL_DRIVER"] = "mobileglues" env["MOBILEGLUES_GLES_VERSION"] = "3.2" env["FNA3D_MOJOSHADER_PROFILE"] = "glsles3" - env["MG_DIR_PATH"] = - Path(Environment.getExternalStorageDirectory().absolutePath).resolve("MG").toString() + val mgDir = File( + Environment.getExternalStorageDirectory().absolutePath, + "MG" + ).absolutePath + env["MG_DIR_PATH"] = mgDir } ) @@ -103,6 +107,7 @@ actual object RendererRegistry { needsPreload = true, minAndroidVersion = Build.VERSION_CODES.N ) { _, env -> + env["RALCORE_RENDERER"] = "angle" env["RALCORE_EGL"] = "libEGL_angle.so" env["LIBGL_GLES"] = "libGLESv2_angle.so" } @@ -178,37 +183,31 @@ actual object RendererRegistry { @JvmStatic fun getCompatibleRenderers(): MutableList { - val context = getGlobalContext() val compatible = mutableListOf() - val nativeLibDir = File(context.applicationInfo.nativeLibraryDir) - val runtimeLibsDir = File(context.filesDir, RUNTIME_LIBS_DIR) - val renderers = synchronized(rendererStore) { rendererStore.values.toList() } + for (renderer in renderers) { - if (Build.VERSION.SDK_INT < renderer.minAndroidVersion) { - continue - } + if (Build.VERSION.SDK_INT < renderer.minAndroidVersion) continue var hasLibraries = true - if (renderer.eglLibrary != null) { - val eglLibNative = File(nativeLibDir, renderer.eglLibrary) - val eglLibRuntime = File(runtimeLibsDir, renderer.eglLibrary) - if (!eglLibNative.exists() && !eglLibRuntime.exists()) { - hasLibraries = false - } + + if (renderer.eglLibrary != null && getRendererLibraryPath(renderer.eglLibrary) == null) { + hasLibraries = false } if (hasLibraries && renderer.glesLibrary != null && renderer.glesLibrary != renderer.eglLibrary) { - val glesLibNative = File(nativeLibDir, renderer.glesLibrary) - val glesLibRuntime = File(runtimeLibsDir, renderer.glesLibrary) - if (!glesLibNative.exists() && !glesLibRuntime.exists()) { + if (getRendererLibraryPath(renderer.glesLibrary) == null) { hasLibraries = false } } - if (hasLibraries) { - compatible.add(renderer) + if (hasLibraries && renderer.id == ID_GL4ES_ANGLE) { + if (getRendererLibraryPath("libGLESv2_angle.so") == null) { + hasLibraries = false + } } + + if (hasLibraries) compatible.add(renderer) } return compatible @@ -239,8 +238,16 @@ actual object RendererRegistry { fun getRendererLibraryPath(libraryName: String?): String? { if (libraryName == null) return null val context = getGlobalContext() + + val runtimeLibDir = File(context.filesDir, RUNTIME_LIBS_DIR) + val runtimeLib = File(runtimeLibDir, libraryName) + if (runtimeLib.exists()) return runtimeLib.absolutePath + val nativeLibDir = File(context.applicationInfo.nativeLibraryDir) - return File(nativeLibDir, libraryName).absolutePath + val nativeLib = File(nativeLibDir, libraryName) + if (nativeLib.exists()) return nativeLib.absolutePath + + return null } @JvmStatic @@ -252,6 +259,37 @@ actual object RendererRegistry { return envMap } + @JvmStatic + fun getRendererPreloadLibraries(rendererId: String): List { + return when (normalizeRendererId(rendererId)) { + ID_ANGLE -> listOf( + "libEGL_angle.so", + "libGLESv2_angle.so" + ) + ID_GL4ES -> listOf( + "libEGL_gl4es.so", + "libGL_gl4es.so" + ) + ID_GL4ES_ANGLE -> listOf( + "libEGL_angle.so", + "libGLESv2_angle.so", + "libGL_gl4es.so" + ) + ID_MOBILEGLUES -> listOf( + "libmobileglues.so" + ) + ID_ZINK -> listOf( + "libOSMesa.so" + ) + else -> emptyList() + } + } + + @JvmStatic + fun getRendererPreloadLibraryPaths(rendererId: String): List { + return getRendererPreloadLibraries(rendererId).mapNotNull { getRendererLibraryPath(it) } + } + private fun getGlobalContext(): Context { val context: Context = GlobalContext.get().get(Context::class, null, null) return context.applicationContext diff --git a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonControlLayoutStorage.kt b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonControlLayoutStorage.kt index 95713b8a..de393994 100644 --- a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonControlLayoutStorage.kt +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonControlLayoutStorage.kt @@ -9,23 +9,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.Path -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.listDirectoryEntries -import kotlin.io.path.name -import kotlin.io.path.readText -import kotlin.io.path.writeText +import java.io.File // Thay kotlin.io.path.* -/** - * 控制布局存储通用实现。 - * - * 平台层仅提供目录路径。 - */ -@OptIn(ExperimentalPathApi::class) class CommonControlLayoutStorage( private val pathsProvider: StoragePathsProvider ) : ControlLayoutStorage { @@ -36,49 +21,49 @@ class CommonControlLayoutStorage( } override suspend fun loadAllLayouts(): List = withContext(Dispatchers.Default) { - layoutsDirPathFull.createDirectories() - layoutsDirPathFull.listDirectoryEntries() - .filter { it.isDirectory().not() } - .filter { it.name.endsWith(".json") } - .filter { it.name != AppConstants.Files.CONTROL_LAYOUT_STATE } - .mapNotNull { filePathFull -> + layoutsDirFull.mkdirs() + layoutsDirFull.listFiles() + ?.filter { it.isFile } + ?.filter { it.name.endsWith(".json") } + ?.filter { it.name != AppConstants.Files.CONTROL_LAYOUT_STATE } + ?.mapNotNull { file -> runCatching { - json.decodeFromString(filePathFull.readText()) + json.decodeFromString(file.readText()) }.getOrNull() - } + } ?: emptyList() } override suspend fun saveLayout(layout: ControlLayout) = withContext(Dispatchers.Default) { - layoutsDirPathFull.createDirectories() - layoutFilePathFull(layout.id).writeText(json.encodeToString(layout)) + layoutsDirFull.mkdirs() + layoutFileFull(layout.id).writeText(json.encodeToString(layout)) } override suspend fun deleteLayout(id: String) = withContext(Dispatchers.Default) { - layoutFilePathFull(id).deleteIfExists() + val file = layoutFileFull(id) + if (file.exists()) file.delete() Unit } override suspend fun loadCurrentLayoutId(): String? = withContext(Dispatchers.Default) { - if (!layoutStatePathFull.exists()) return@withContext null + if (!layoutStateFull.exists()) return@withContext null runCatching { - json.decodeFromString(layoutStatePathFull.readText()).currentLayoutId + json.decodeFromString(layoutStateFull.readText()).currentLayoutId }.getOrNull() } override suspend fun saveCurrentLayoutId(id: String) = withContext(Dispatchers.Default) { - layoutsDirPathFull.createDirectories() + layoutsDirFull.mkdirs() val state = ControlLayoutState(currentLayoutId = id) - layoutStatePathFull.writeText(json.encodeToString(state)) + layoutStateFull.writeText(json.encodeToString(state)) } override suspend fun importPack(packPath: String): Result = withContext(Dispatchers.Default) { runCatching { - val packPathFull = Path(packPath) - if (!packPathFull.exists()) { + val packFile = File(packPath) + if (!packFile.exists()) { throw IllegalArgumentException("File not found: $packPath") } - - val layout = json.decodeFromString(packPathFull.readText()) + val layout = json.decodeFromString(packFile.readText()) saveLayout(layout) layout } @@ -87,26 +72,25 @@ class CommonControlLayoutStorage( override suspend fun exportLayout(layout: ControlLayout, outputPath: String): Result = withContext(Dispatchers.Default) { runCatching { - val outputPathFull = Path(outputPath) - outputPathFull.parent?.createDirectories() - outputPathFull.writeText(json.encodeToString(layout)) + val outputFile = File(outputPath) + outputFile.parentFile?.mkdirs() + outputFile.writeText(json.encodeToString(layout)) outputPath } } override suspend fun fetchRemotePacks(): Result> { - // 远程获取控件包功能暂未实现 return Result.success(emptyList()) } - private val layoutsDirPathFull - get() = Path(pathsProvider.controlLayoutsDirPathFull()) + private val layoutsDirFull + get() = File(pathsProvider.controlLayoutsDirPathFull()) - private fun layoutFilePathFull(layoutId: String) = - layoutsDirPathFull.resolve("$layoutId.json") + private fun layoutFileFull(layoutId: String) = + File(layoutsDirFull, "$layoutId.json") - private val layoutStatePathFull - get() = layoutsDirPathFull.resolve(AppConstants.Files.CONTROL_LAYOUT_STATE) + private val layoutStateFull + get() = File(layoutsDirFull, AppConstants.Files.CONTROL_LAYOUT_STATE) } @Serializable diff --git a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonGameListStorage.kt b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonGameListStorage.kt index 718cbc83..517166b6 100644 --- a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonGameListStorage.kt +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonGameListStorage.kt @@ -6,25 +6,9 @@ import com.app.ralaunch.shared.core.model.domain.GameItem import com.app.ralaunch.shared.core.model.domain.GameList import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlin.io.path.Path -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteRecursively -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.listDirectoryEntries -import kotlin.io.path.name -import kotlin.io.path.readText -import kotlin.io.path.writeText +import java.io.File // Thay kotlin.io.path.* import kotlin.random.Random -/** - * 跨平台游戏列表管理逻辑。 - * - * 平台层只提供目录路径,具体的列表索引、游戏信息序列化、 - * 存储 ID 生成和清理逻辑都在 commonMain 中统一处理。 - */ -@OptIn(ExperimentalPathApi::class) class CommonGameListStorage( private val pathsProvider: StoragePathsProvider ) : GameListStorage { @@ -37,9 +21,9 @@ class CommonGameListStorage( override fun loadGameList(): List { return try { - if (!gameListPathFull.exists()) return emptyList() + if (!gameListFile.exists()) return emptyList() - val gameList = json.decodeFromString(gameListPathFull.readText()) + val gameList = json.decodeFromString(gameListFile.readText()) gameList.games.mapNotNull { storageRootPathRelative -> loadGameInfo(storageRootPathRelative) } @@ -51,10 +35,10 @@ class CommonGameListStorage( override fun saveGameList(games: List) { try { - gamesDirPathFull.createDirectories() + gamesDirFile.mkdirs() val gameList = GameList(games = games.map { it.id }) - gameListPathFull.writeText(json.encodeToString(gameList)) + gameListFile.writeText(json.encodeToString(gameList)) games.forEach { saveGameInfo(it) } cleanupDeletedGameStorageRoots(games.map { it.id }) @@ -63,25 +47,25 @@ class CommonGameListStorage( } } - override fun getGameGlobalStorageDirFull(): String = gamesDirPathFull.toString() + override fun getGameGlobalStorageDirFull(): String = gamesDirFile.absolutePath override fun createGameStorageRoot(gameId: String): Pair { val baseName = gameId.replace(Regex("[^a-zA-Z0-9\\u4e00-\\u9fa5]"), "_") val storageRootPathRelative = "${baseName}_${randomHex(8)}" - val storageRootPathFull = gamesDirPathFull.resolve(storageRootPathRelative) - storageRootPathFull.createDirectories() - return Pair(storageRootPathFull.toString(), storageRootPathRelative) + val storageRootFile = File(gamesDirFile, storageRootPathRelative) + storageRootFile.mkdirs() + return Pair(storageRootFile.absolutePath, storageRootPathRelative) } private fun loadGameInfo(storageRootPathRelative: String): GameItem? { return try { - val gameInfoPathFull = gamesDirPathFull - .resolve(storageRootPathRelative) - .resolve(AppConstants.Files.GAME_INFO) - if (!gameInfoPathFull.exists()) return null + val gameInfoFile = File( + File(gamesDirFile, storageRootPathRelative), + AppConstants.Files.GAME_INFO + ) + if (!gameInfoFile.exists()) return null - val content = gameInfoPathFull.readText() - json.decodeFromString(content).also { + json.decodeFromString(gameInfoFile.readText()).also { it.gameListStorageParent = this } } catch (e: Exception) { @@ -92,26 +76,25 @@ class CommonGameListStorage( private fun saveGameInfo(game: GameItem) { try { - val storageRootPathRelative = game.id - val storageRootPathFull = gamesDirPathFull.resolve(storageRootPathRelative) - val gameInfoPathFull = storageRootPathFull.resolve(AppConstants.Files.GAME_INFO) + val storageRootFile = File(gamesDirFile, game.id) + val gameInfoFile = File(storageRootFile, AppConstants.Files.GAME_INFO) - storageRootPathFull.createDirectories() - gameInfoPathFull.writeText(json.encodeToString(game)) + storageRootFile.mkdirs() + gameInfoFile.writeText(json.encodeToString(game)) } catch (e: Exception) { e.printStackTrace() } } private fun cleanupDeletedGameStorageRoots(keepRoots: List) { - if (!gamesDirPathFull.exists()) return - - val keepStorageRootsRelative = keepRoots.toSet() - gamesDirPathFull.listDirectoryEntries() - .filter { it.isDirectory() } - .filter { it.name !in keepStorageRootsRelative } - .forEach { - runCatching { it.deleteRecursively() } + if (!gamesDirFile.exists()) return + + val keepSet = keepRoots.toSet() + gamesDirFile.listFiles() + ?.filter { it.isDirectory } + ?.filter { it.name !in keepSet } + ?.forEach { dir -> + runCatching { dir.deleteRecursively() } } } @@ -124,10 +107,9 @@ class CommonGameListStorage( } } - private val gamesDirPathFull - get() = Path(pathsProvider.gamesDirPathFull()) - - private val gameListPathFull - get() = gamesDirPathFull.resolve(AppConstants.Files.GAME_LIST) + private val gamesDirFile + get() = File(pathsProvider.gamesDirPathFull()) + private val gameListFile + get() = File(gamesDirFile, AppConstants.Files.GAME_LIST) } diff --git a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/repository/SettingsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/repository/SettingsRepositoryImpl.kt index 874f6b14..3bac24b3 100644 --- a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/repository/SettingsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/repository/SettingsRepositoryImpl.kt @@ -11,19 +11,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import kotlin.io.path.Path -import kotlin.io.path.createDirectories -import kotlin.io.path.exists -import kotlin.io.path.moveTo -import kotlin.io.path.name -import kotlin.io.path.readText -import kotlin.io.path.writeText - -/** - * 设置仓库实现(V2) - * - * 单一持久化来源:JSON 文件(settings.json)。 - */ +import java.io.File // ← Thay kotlin.io.path.* bằng java.io.File + class SettingsRepositoryImpl( private val storagePathsProvider: StoragePathsProvider ) : SettingsRepositoryV2 { @@ -35,7 +24,9 @@ class SettingsRepositoryImpl( encodeDefaults = true } - private val settingsFilePathFull = Path(storagePathsProvider.settingsFilePathFull()) + // Dùng java.io.File thay vì kotlin.io.path.Path + private val settingsFile = File(storagePathsProvider.settingsFilePathFull()) + @Volatile private var currentSettings: AppSettings = loadSettingsFromDisk() private val _settings = MutableStateFlow(currentSettings.copy()) @@ -68,9 +59,9 @@ class SettingsRepositoryImpl( private fun loadSettingsFromDisk(): AppSettings { return runCatching { ensureParentDirectory() - if (!settingsFilePathFull.exists()) return@runCatching AppSettings.Default + if (!settingsFile.exists()) return@runCatching AppSettings.Default - val raw = settingsFilePathFull.readText() + val raw = settingsFile.readText() json.decodeFromString(raw) }.getOrElse { backupCorruptedFile() @@ -82,23 +73,33 @@ class SettingsRepositoryImpl( ensureParentDirectory() val serialized = json.encodeToString(settings) - val tempPathFull = settingsFilePathFull.resolveSibling("${settingsFilePathFull.name}.tmp") - tempPathFull.writeText(serialized) - tempPathFull.moveTo(settingsFilePathFull, overwrite = true) + // Dùng File thay vì Path + val tempFile = File(settingsFile.parent, "${settingsFile.name}.tmp") + tempFile.writeText(serialized) + + // Atomic move + if (!tempFile.renameTo(settingsFile)) { + // Nếu renameTo thất bại (khác partition), dùng copy + delete + settingsFile.writeText(serialized) + tempFile.delete() + } + return settings } private fun ensureParentDirectory() { - settingsFilePathFull.parent?.createDirectories() + settingsFile.parentFile?.mkdirs() } private fun backupCorruptedFile() { runCatching { - if (!settingsFilePathFull.exists()) return - val backupPathFull = settingsFilePathFull.resolveSibling( - "${settingsFilePathFull.name}.corrupt.${System.currentTimeMillis()}" + if (!settingsFile.exists()) return + val backupFile = File( + settingsFile.parent, + "${settingsFile.name}.corrupt.${System.currentTimeMillis()}" ) - settingsFilePathFull.moveTo(backupPathFull, overwrite = true) + // Dùng renameTo thay vì moveTo + settingsFile.renameTo(backupFile) } } } diff --git a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/model/domain/GameItem.kt b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/model/domain/GameItem.kt index aa1012d3..b582b303 100644 --- a/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/model/domain/GameItem.kt +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/model/domain/GameItem.kt @@ -3,25 +3,8 @@ package com.app.ralaunch.shared.core.model.domain import com.app.ralaunch.shared.core.data.repository.GameListStorage import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import kotlin.io.path.Path +import java.io.File // Thay kotlin.io.path.Path -/** - * 游戏项数据模型 - 统一跨平台版本 - * - * 序列化到 game_info.json,使用相对路径存储。 - * 通过 gameListStorageParent 延迟解析绝对路径。 - * - * @property id 唯一安装标识符,用作存储目录名(游戏名+随机哈希) - * @property displayedName 显示名称 - * @property displayedDescription 描述信息 - * @property gameId 游戏类型标识符(如 "celeste", "terraria") - * @property gameExePathRelative 游戏主程序相对路径(相对于游戏存储根目录) - * @property iconPathRelative 图标相对路径(相对于游戏存储根目录),可为空 - * @property modLoaderEnabled 是否启用 ModLoader - * @property rendererOverride 渲染器覆盖(null 表示跟随全局设置) - * @property gameEnvVars 游戏环境变量(null 值表示在启动前 unset 对应变量) - * @property gameListStorageParent 父存储实例引用(非序列化,反序列化后需手动设置) - */ @Serializable data class GameItem( val id: String, @@ -37,56 +20,30 @@ data class GameItem( @Transient var gameListStorageParent: GameListStorage? = null ) { - /** - * 游戏存储目录名称(相对于全局 games 目录) - * - * 等同于 [id],返回子目录名称。 - * 例: 全局目录为 `/data/games/`,此属性返回 `"celeste_a1b2c3d4"`, - * 则存储根目录为 `/data/games/celeste_a1b2c3d4/` - */ @Transient val storageRootPathRelative: String get() = id - /** - * 游戏存储根目录的绝对路径 - * - * 通过全局存储目录与 [id] 拼接得到。 - * 若 [gameListStorageParent] 未设置则返回 null。 - * - * 例: `/data/games/celeste_a1b2c3d4/` - */ @Transient val storageRootPathFull: String? get() = gameListStorageParent?.getGameGlobalStorageDirFull()?.let { - Path(it).resolve(id).toString() + // Thay Path(it).resolve(id) bằng File(it, id) + File(it, id).absolutePath } - /** - * 游戏主程序的绝对路径 - * - * 通过 [storageRootPathFull] 与 [gameExePathRelative] 拼接得到。 - * 若 [storageRootPathFull] 无法确定则返回 null。 - * - * 例: `/data/games/celeste_a1b2c3d4/Celeste.exe` - */ @Transient val gameExePathFull: String? get() = storageRootPathFull?.let { - Path(it).resolve(gameExePathRelative).toString() + // Thay Path(it).resolve(gameExePathRelative) + File(it, gameExePathRelative).absolutePath } - /** - * 游戏图标的绝对路径 - * - * 通过 [storageRootPathFull] 与 [iconPathRelative] 拼接得到。 - * 若 [iconPathRelative] 未设置或 [storageRootPathFull] 无法确定则返回 null。 - * - * 例: `/data/games/celeste_a1b2c3d4/icon.png` - */ @Transient val iconPathFull: String? get() = iconPathRelative?.let { - storageRootPathFull?.let { base -> Path(base).resolve(it).toString() } + storageRootPathFull?.let { base -> + // Thay Path(base).resolve(it) + File(base, it).absolutePath + } } }