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..d8fd2dd7 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -1,16 +1,11 @@ -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" @@ -21,77 +16,15 @@ env: 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 - 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 + - name: 🔎 Checkout Source Code + uses: actions/checkout@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- + lfs: false + submodules: false - # ==================== 环境设置 ==================== - - name: Setup JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' # 与上面的缓存互补 - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - name: Install SDK Components + - name: 🌐 Install SDK Components id: install_sdk run: | echo "::group::安装 SDK 组件" @@ -106,233 +39,87 @@ jobs: yes | sdkmanager --licenses || true echo "installed=true" >> $GITHUB_OUTPUT - - 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: 🚫 Disable Global Git LFS run: | - echo "::group::构建 Debug 版本" - ./gradlew clean assembleDebug --no-daemon --stacktrace 2>&1 | tee debug-build.log - echo "::endgroup::" - continue-on-error: true + git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f" + git config --global filter.lfs.process "git-lfs filter-process --skip" - - name: Debug Build Error Report - if: steps.build_debug.outcome == 'failure' + - name: 🔁 Force Update Broken Submodules run: | - echo "::error::Debug 构建失败!查看日志: debug-build.log" - tail -100 debug-build.log || true - # 上传错误日志 - exit 1 + git submodule update --remote --init --recursive || true - - 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 - - if [ ! -f "debug.keystore" ]; then - echo "::error::密钥生成失败" - exit 1 - fi - - echo "keystore_generated=true" >> $GITHUB_OUTPUT - - - name: Build Safe Release APK - id: build_safe - if: github.event.inputs.build_safe == 'true' && steps.gen_key.outcome == 'success' + mkdir -p app/src/main/assets/ + cd app/src/main/assets/ + rm -f MonoMod.zip runtime_libs.tar.xz dotnet.tar.xz + 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: 💉 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 签名完成" - - - 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 + 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: 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 - - echo "📊 最终磁盘空间:" - df -h / - echo "::endgroup::" + APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1) + BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/34.0.0" + $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..0deae0a6 --- /dev/null +++ b/.github/workflows/sync-fork.yml @@ -0,0 +1,64 @@ +name: Sync Fork (Keep My Code) + +on: + schedule: + - cron: '0 */23 * * *' + 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 + exit 1 + fi + echo "repo=$UPSTREAM" >> $GITHUB_OUTPUT + + - name: Config Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Force Merge Upstream + id: merge + run: | + git remote add upstream https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ steps.upstream.outputs.repo }}.git + git fetch upstream main + + BEFORE=$(git rev-parse HEAD) + + if ! git merge upstream/main --allow-unrelated-histories -X ours --no-edit; then + echo "Conflicts detected, forcing our versions..." + git checkout --ours . + git add . + git commit -m "Sync: Force resolve conflicts using main branch code" + fi + + AFTER=$(git rev-parse HEAD) + if [ "$BEFORE" != "$AFTER" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "changed=false" >> $GITHUB_OUTPUT + fi + + - name: Push to Main + if: steps.merge.outputs.changed == 'true' + run: | + git push https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository }}.git main diff --git a/README.md b/README.md index 4cad15e9..933beea0 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.kts b/app/build.gradle.kts index 36971684..7cc2420b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,14 +11,15 @@ plugins { android { namespace = "com.app.ralaunch" compileSdk = 37 - ndkVersion = "30.0.14904198 rc1" + ndkVersion = "28.0.13004108" defaultConfig { applicationId = "com.app.ralaunch" - minSdk = 28 + minSdk = 24 targetSdk = 37 versionCode = 4 versionName = "2.1.1" + multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -31,13 +32,14 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlin { compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) + jvmTarget.set(JvmTarget.JVM_17) } } @@ -162,4 +164,5 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) -} \ No newline at end of file + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 66fffce9..773cf055 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 new file mode 100644 index 00000000..814165ad --- /dev/null +++ b/app/src/main/cpp/liblinkernsbypass/android_linker_ns.cpp @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: BSD-2-Clause +// Copyright © 2021 Billy Laws + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "elf_soname_patcher.h" +#include "android_linker_ns.h" + +#ifdef __cplusplus +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, + const char *, + android_namespace_t *, + const void *); +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 */ +bool linkernsbypass_load_status() { + return lib_loaded; +} + +struct android_namespace_t *android_create_namespace(const char *name, + const char *ld_library_path, + const char *default_library_path, + 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); +} + +struct android_namespace_t *android_create_namespace_escape(const char *name, + const char *ld_library_path, + const char *default_library_path, + 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); +} + +android_get_exported_namespace_t android_get_exported_namespace; + +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) { + // === 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)}; + return android_link_namespaces_all_libs(to, defaultNs); +} + +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 + }; + + return android_dlopen_ext(filename, flags, &extInfo); +} + +#ifndef __NR_memfd_create +#if defined(__aarch64__) +#define __NR_memfd_create 279 +#else +#error Unsupported target architecture! +#endif +#endif + +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 + static uint16_t TargetId{}; + + int libTargetFd{[&]() { + if (libTargetDir) { + snprintf(PathBuf.data(), PathBuf.size(), "%s/%d_patched.so", libTargetDir, TargetId); + return open(PathBuf.data(), O_CREAT | O_RDWR, S_IRUSR | S_IWUSR); + } else { + // If memfd isn't supported errno will contain ENOSYS after calling + errno = 0; + int fd{static_cast(syscall(__NR_memfd_create, libPath, 0))}; + if (errno == ENOSYS || fd < 0) + return -1; + else + return fd; + } + }()}; + if (libTargetFd == -1) + return nullptr; + + // Partially overwrite soname with 3 digits (replacing lib...) with to make sure a cached so isn't loaded + std::array sonameOverwrite{}; + snprintf(sonameOverwrite.data(), sonameOverwrite.size(), "%03u", TargetId++); + + if (!elf_soname_patch(libPath, libTargetFd, sonameOverwrite.data())) + return nullptr; + + // Load our patched library into the hook namespace + android_dlextinfo hookExtInfo{ + .flags = ANDROID_DLEXT_USE_NAMESPACE | ANDROID_DLEXT_USE_LIBRARY_FD, + .library_fd = libTargetFd, + .library_namespace = ns + }; + + // Make a path that looks about right + snprintf(PathBuf.data(), PathBuf.size(), "/proc/self/fd/%d", libTargetFd); + + return android_dlopen_ext(PathBuf.data(), flags, &hookExtInfo); +} + +static void *align_ptr(void *ptr) { + return reinterpret_cast(reinterpret_cast(ptr) & ~(getpagesize() - 1)); +} + +/* Private */ +__attribute__((constructor)) static void resolve_linker_symbols() { + using loader_dlopen_t = void *(*)(const char *, int, const void *); + + // === 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{[]() { + union BranchLinked { + uint32_t raw; + + struct { + int32_t offset: 26; //!< 26-bit branch offset + uint8_t sig: 6; //!< 6-bit signature + }; + + bool Verify() { + return sig == 0x25; + } + }; + static_assert(sizeof(BranchLinked) == 4, "BranchLinked is wrong size"); + + // 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 + auto blInstr{reinterpret_cast(&dlopen)}; + while (!blInstr->Verify()) + blInstr++; + + return reinterpret_cast(blInstr + blInstr->offset); + }()}; + + mprotect(align_ptr(reinterpret_cast(&loader_dlopen)), getpagesize(), + PROT_WRITE | PROT_READ | PROT_EXEC); + + auto ldHandle{loader_dlopen("ld-android.so", RTLD_LAZY, reinterpret_cast(&dlopen))}; + if (!ldHandle) + return; + + android_link_namespaces_all_libs = reinterpret_cast(dlsym( + ldHandle, "__loader_android_link_namespaces_all_libs")); + if (!android_link_namespaces_all_libs) + return; + + android_link_namespaces = reinterpret_cast(dlsym(ldHandle, + "__loader_android_link_namespaces")); + if (!android_link_namespaces) + return; + + auto libdlAndroidHandle{ + loader_dlopen("libdl_android.so", RTLD_LAZY, reinterpret_cast(&dlopen))}; + if (!libdlAndroidHandle) + return; + + loader_android_create_namespace = reinterpret_cast(dlsym( + libdlAndroidHandle, "__loader_android_create_namespace")); + if (!loader_android_create_namespace) + return; + + android_get_exported_namespace = reinterpret_cast(dlsym( + libdlAndroidHandle, "__loader_android_get_exported_namespace")); + if (!android_get_exported_namespace) + return; + + // Lib is now safe to use + lib_loaded = true; + LOGI("Linker namespace bypass loaded successfully on API %d", device_api_level); +} + +#ifdef __cplusplus +} +#endif diff --git a/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt b/app/src/main/java/com/app/ralaunch/RaLaunchApp.kt index bbd09868..e9e386b1 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 com.app.ralaunch.core.logging.AppLog import androidx.appcompat.app.AppCompatDelegate @@ -21,13 +22,8 @@ import com.kyant.fishnet.Fishnet import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent import java.io.File -import java.io.InvalidObjectException +import com.app.ralaunch.core.platform.runtime.BlackBoxLogger -/** - * 应用程序 Application 类 (Kotlin 重构版) - * - * 使用 Koin DI 框架管理依赖 - */ class RaLaunchApp : Application(), KoinComponent { companion object { @@ -36,22 +32,15 @@ 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: VibrationManagerServiceV1 by inject() + private val _vibrationManager: VibrationManager by inject() private val _controlPackManager: ControlPackManager by inject() private val _patchManager: PatchManager? by inject() private val _runtimeManager: IRuntimeManagerServiceV2 by inject() @@ -62,29 +51,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. 初始化进程级文件日志捕获 - initFileLogger() - - // 4. 启动时迁移旧运行时布局,仅在主进程执行一次 - runRuntimeMigrationOnAppLaunch() - - // 5. 应用主题设置 - applyThemeFromSettings() + fun writeLog(msg: String) { + Log.i(TAG, msg) + try { startupLogFile.appendText("$msg\n") } catch (e: Exception) { } + } - // 6. 初始化崩溃捕获 - 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) + } + } - // 7. 后台安装补丁 - 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() } - // 8. 设置环境变量 - setupEnvironmentVariables() + writeLog("=== Init Complete ===") } override fun attachBaseContext(base: Context) { @@ -98,57 +95,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) { - AppLog.e(TAG, "Failed to apply theme: ${e.message}") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - } - - private fun runRuntimeMigrationOnAppLaunch() { - if (!isMainAppProcess()) return - - try { - _runtimeManager.migrateLegacyInstallations() - } catch (e: Exception) { - AppLog.e(TAG, "Failed to migrate legacy runtimes on app launch: ${e.message}", e) + Log.e(TAG, "Failed to apply theme: ${e.message}") } } - private fun isMainAppProcess(): Boolean = getProcessName() == packageName - - private fun initFileLogger() { + private fun initCrashHandler() { try { - val logDir = File(_storagePathsProvider.logsDirPathFull()) - _fileLogger.start( - logDirectory = logDir, - clearExistingLogs = isMainAppProcess() - ) - AppLog.i(TAG, "File log capture initialized for process=${getProcessName()}, pid=${android.os.Process.myPid()}") + val logDir = File(filesDir, "crash_logs").apply { + if (!exists()) mkdirs() + } + Fishnet.init(applicationContext, logDir.absolutePath) } catch (e: Exception) { - AppLog.e(TAG, "Failed to initialize file log capture", e) + Log.e(TAG, "Fishnet init failed: ${e.message}") } } - /** - * 初始化崩溃捕获 - */ - private fun initCrashHandler() { - val logDir = File(filesDir, "crash_logs").apply { - if (!exists()) mkdirs() - } - Fishnet.init(applicationContext, logDir.absolutePath) - } - - /** - * 后台安装补丁 - */ private fun installPatchesInBackground() { _patchManager?.let { manager -> Thread({ @@ -162,37 +131,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) - AppLog.d(TAG, "EXTERNAL_STORAGE_DIRECTORY: ${it.absolutePath}") } } catch (e: Exception) { AppLog.e(TAG, "Failed to set environment variables: ${e.message}") } } - // ==================== 兼容旧代码的访问方法 ==================== - - /** - * 获取 VibrationManagerServiceV1 - */ - fun getVibrationManager(): VibrationManagerServiceV1 = _vibrationManager - - /** - * 获取 ControlPackManager - */ + fun getVibrationManager(): VibrationManager = _vibrationManager 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 new file mode 100644 index 00000000..b4c8526a --- /dev/null +++ b/app/src/main/java/com/app/ralaunch/core/common/GameDeletionManager.kt @@ -0,0 +1,46 @@ +package com.app.ralaunch.core.common + +import android.content.Context +import com.app.ralaunch.shared.core.model.domain.GameItem +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 + +class GameDeletionManager(private val context: Context) { + + fun deleteGameFiles(game: GameItem): Boolean { + return try { + val gameDir = getGameDirectory(game) ?: return false + + val dirPath = gameDir.absolutePath + if (!dirPath.contains("/files/games/") && !dirPath.contains("/files/imported_games/")) { + return false + } + + // Thay Paths.get() bang File + FileUtils.deleteDirectoryRecursively(gameDir) + } catch (e: Exception) { + AppLogger.error("GameDeletionManager", "Error deleting game files: ${e.message}") + false + } + } + + fun deleteGame(path: String?): Boolean { + if (path.isNullOrEmpty()) return false + return try { + // Thay Paths.get() bang File + FileUtils.deleteDirectoryRecursively(File(path)) + } catch (e: Exception) { + AppLogger.error("GameDeletionManager", "Error deleting game: ${e.message}") + false + } + } + + private fun getGameDirectory(game: GameItem): File? { + if (game.storageRootPathRelative.isBlank()) return null + + val gamesDir = File(context.getExternalFilesDir(null), AppConstants.Dirs.GAMES) + return File(gamesDir, game.storageRootPathRelative) + } +} 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 2fc5310e..7dd48abc 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,22 +3,9 @@ package com.app.ralaunch.core.common.util import com.app.ralaunch.core.logging.AppLog import java.io.File import java.io.IOException -import java.nio.file.AccessDeniedException -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.NoSuchFileException -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.io.path.absolute -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.listDirectoryEntries /** - * 文件操作工具类 + * File operation utilities */ object FileUtils { private const val TAG = "FileUtils" @@ -26,135 +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 - val normalizedPath = path.absolute().normalize() - if (!Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS)) return true - if (!Files.isReadable(normalizedPath)) return false - - val allDeleted = AtomicBoolean(true) + if (!path.exists()) return true + if (!path.canRead()) return false return try { - Files.walkFileTree( - normalizedPath, - object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - if (!deletePathWithRetry(file)) { - allDeleted.set(false) - } - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed(file: Path, exc: IOException?): FileVisitResult { - if (!deletePathWithRetry(file)) { - allDeleted.set(false) - } - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { - if (exc != null) { - allDeleted.set(false) - } - if (!deletePathWithRetry(dir)) { - allDeleted.set(false) - } - return FileVisitResult.CONTINUE - } + path.walkBottomUp().fold(true) { result, file -> + if (!deleteFileWithRetry(file)) { + Log.w(TAG, "Failed to delete: $file") + false + } else { + result } - ) - allDeleted.get() && !Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS) - } catch (e: NoSuchFileException) { - true - } catch (e: AccessDeniedException) { - AppLog.w(TAG, "删除失败(权限): $normalizedPath") + } + } catch (e: IOException) { + Log.w(TAG, "Delete failed: $path") false } catch (e: SecurityException) { - AppLog.w(TAG, "删除失败(权限): $normalizedPath") - false - } catch (e: IOException) { - AppLog.w(TAG, "删除失败: $normalizedPath") + Log.w(TAG, "Delete failed (permission): $path") false } catch (e: Exception) { - AppLog.w(TAG, "删除失败: $normalizedPath", 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 deleteDirectoryRecursivelyWithinRoot(path: Path?, allowedRoot: Path?): Boolean { - val normalizedTarget = normalizePath(path) ?: return false - val normalizedRoot = normalizePath(allowedRoot) ?: return false - if (!isStrictChildOf(normalizedTarget, normalizedRoot)) return false - return deleteDirectoryRecursively(normalizedTarget) - } - - @JvmStatic - fun deleteDirectoryRecursivelyWithinRoot(directory: File?, allowedRoot: File?): Boolean { - val targetPath = directory?.toPath() - val rootPath = allowedRoot?.toPath() - return deleteDirectoryRecursivelyWithinRoot(targetPath, rootPath) - } - - /** - * 仅当文件位于指定根目录内时才删除该文件。 - * 目录不会通过此接口删除。 - */ - @JvmStatic - fun deleteFileWithinRoot(path: Path?, allowedRoot: Path?): Boolean { - val normalizedTarget = normalizePath(path) ?: return false - val normalizedRoot = normalizePath(allowedRoot) ?: return false - if (!isStrictChildOf(normalizedTarget, normalizedRoot)) return false - if (normalizedTarget.isDirectory(LinkOption.NOFOLLOW_LINKS)) return false - return deletePathWithRetry(normalizedTarget) - } - - @JvmStatic - fun deleteFileWithinRoot(file: File?, allowedRoot: File?): Boolean { - val targetPath = file?.toPath() - val rootPath = allowedRoot?.toPath() - return deleteFileWithinRoot(targetPath, rootPath) - } - - /** - * 清空目录内容,但保留目录本身。 - */ - @JvmStatic - fun clearDirectory(directory: File?): Boolean { - val normalizedDirectory = directory?.toPath()?.absolute()?.normalize() ?: return false - if (!normalizedDirectory.exists(LinkOption.NOFOLLOW_LINKS)) return true - if (!normalizedDirectory.isDirectory(LinkOption.NOFOLLOW_LINKS)) return false - - return normalizedDirectory.listDirectoryEntries().all { child -> - if (child.isDirectory(LinkOption.NOFOLLOW_LINKS)) { - deleteDirectoryRecursivelyWithinRoot(child, normalizedDirectory) - } else { - deleteFileWithinRoot(child, normalizedDirectory) - } - } + 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) { @@ -176,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 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 deleteDirectoryRecursively(directory: File?): Boolean { - if (directory == null) return false - if (!directory.exists()) return true - if (!directory.canRead()) return false + 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 cdbc7d6e..519e6ec1 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,12 +4,14 @@ import android.content.Context import com.app.ralaunch.core.di.contract.IGameRepositoryServiceV3 import com.app.ralaunch.core.logging.AppLog import com.app.ralaunch.core.platform.runtime.AssemblyPatcher -import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import com.app.ralaunch.shared.core.contract.repository.GameRepositoryV2 import org.koin.java.KoinJavaComponent 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 /** * 补丁提取工具 @@ -47,31 +49,44 @@ object PatchExtractor { if (monoModDir.exists()) FileUtils.deleteDirectoryRecursivelyWithinRoot(monoModDir, context.filesDir) 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 70948e97..7c619ef4 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 com.app.ralaunch.core.logging.AppLog 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.deleteDirectoryRecursivelyWithinRoot(tmpFilePath, preferredTempDir) + tmpFiles.forEach { tmpFile -> + val isSuccessful = FileUtils.deleteDirectoryRecursively(tmpFile) if (!isSuccessful) { - AppLog.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/di/service/SettingsRepositoryServiceV2.kt b/app/src/main/java/com/app/ralaunch/core/di/service/SettingsRepositoryServiceV2.kt index 9198082e..0a696550 100644 --- a/app/src/main/java/com/app/ralaunch/core/di/service/SettingsRepositoryServiceV2.kt +++ b/app/src/main/java/com/app/ralaunch/core/di/service/SettingsRepositoryServiceV2.kt @@ -9,22 +9,11 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json 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)。 - */ -class SettingsRepositoryServiceV2( - private val storagePathsProvider: StoragePathsProviderServiceV1 -) : ISettingsRepositoryServiceV2 { +import java.io.File // ← Thay kotlin.io.path.* bằng java.io.File + +class SettingsRepositoryImpl( + private val storagePathsProvider: StoragePathsProvider +) : SettingsRepositoryV2 { private val writeMutex = Mutex() private val json = Json { @@ -33,7 +22,9 @@ class SettingsRepositoryServiceV2( 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()) @@ -66,9 +57,9 @@ class SettingsRepositoryServiceV2( 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() @@ -80,23 +71,33 @@ class SettingsRepositoryServiceV2( 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/app/src/main/java/com/app/ralaunch/core/extractor/ArchiveExtractor.kt b/app/src/main/java/com/app/ralaunch/core/extractor/ArchiveExtractor.kt index a5bebfb0..580d23e7 100644 --- a/app/src/main/java/com/app/ralaunch/core/extractor/ArchiveExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/extractor/ArchiveExtractor.kt @@ -1,27 +1,13 @@ package com.app.ralaunch.core.extractor import android.content.Context -import android.system.Os -import com.app.ralaunch.core.logging.AppLog -import com.app.ralaunch.core.common.util.FileUtils -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream -import org.apache.commons.compress.compressors.xz.XZCompressorInputStream -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.nio.file.Files -import java.nio.file.StandardCopyOption +import org.tukaani.xz.XZInputStream +import java.io.* 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) @@ -30,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) } } } @@ -44,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) } } } @@ -58,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(targetDir, targetFile, entry.linkName) - else -> extractFile(tarIn, targetFile, entry.mode) + entry.isSymbolicLink -> extractSymlink(targetFile, entry.linkName) + 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 + } } } @@ -139,26 +125,28 @@ object ArchiveExtractor { try { Os.symlink(linkTarget, targetFile.absolutePath) } catch (e: Exception) { - AppLog.w(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) { - AppLog.w(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) + } } } @@ -170,8 +158,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/extractor/BasicSevenZipExtractor.kt b/app/src/main/java/com/app/ralaunch/core/extractor/BasicSevenZipExtractor.kt index d9ab48ac..038a510c 100644 --- a/app/src/main/java/com/app/ralaunch/core/extractor/BasicSevenZipExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/extractor/BasicSevenZipExtractor.kt @@ -5,61 +5,86 @@ import com.app.ralaunch.R import com.app.ralaunch.RaLaunchApp import net.sf.sevenzipjbinding.* import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream +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 + + @Synchronized + fun ensureLibraryLoaded(): Boolean { + if (libraryLoaded) return true + + try { + 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}") + try { + val context = RaLaunchApp.getInstance() + libraryLoaded = RuntimeLibraryLoader.load7Zip(context) + if (libraryLoaded) { + Log.i(TAG, "7-Zip native library loaded from runtime_libs") + } + } catch (e2: Exception) { + Log.e(TAG, "Cannot load 7-Zip library from runtime_libs: ${e2.message}") + } + } catch (e: Exception) { + Log.e(TAG, "Cannot load 7-Zip library: ${e.message}") + } + return libraryLoaded + } } - 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?) { @@ -67,22 +92,121 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { } override fun extract(): Boolean { - return try { - if (!Files.exists(destinationPath)) { - Files.createDirectories(destinationPath) + if (!destinationFile.exists()) { + destinationFile.mkdirs() + } + + // ... 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 + } - RandomAccessFile(sourcePath.toString(), "r").use { raf -> - RandomAccessFileInStream(raf).use { inStream -> - SevenZip.openInArchive(null, inStream).use { archive -> - val totalItems = archive.numberOfItems - AppLog.d(TAG, "Archive contains $totalItems items") - archive.extract(null, false, ArchiveExtractCallback(archive)) + // ... 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") + } + + 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() } } } - AppLog.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) @@ -90,24 +214,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 @@ -116,33 +237,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( @@ -151,8 +279,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) @@ -160,8 +287,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) { @@ -184,18 +310,15 @@ class BasicSevenZipExtractor : ExtractorCollection.IExtractor { try { it.close() outputStream = null - } catch (_: IOException) { - throw SevenZipException("Error closing file: $currentProcessingFilePath") + } catch (e: IOException) { + 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/extractor/ExtractorCollection.kt b/app/src/main/java/com/app/ralaunch/core/extractor/ExtractorCollection.kt index 2723f268..0d1d1fd7 100644 --- a/app/src/main/java/com/app/ralaunch/core/extractor/ExtractorCollection.kt +++ b/app/src/main/java/com/app/ralaunch/core/extractor/ExtractorCollection.kt @@ -1,11 +1,8 @@ package com.app.ralaunch.core.extractor -import com.app.ralaunch.core.logging.AppLog -import java.nio.file.Path +import android.util.Log +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/extractor/GogShFileExtractor.kt b/app/src/main/java/com/app/ralaunch/core/extractor/GogShFileExtractor.kt index 0a358b4a..6198e16e 100644 --- a/app/src/main/java/com/app/ralaunch/core/extractor/GogShFileExtractor.kt +++ b/app/src/main/java/com/app/ralaunch/core/extractor/GogShFileExtractor.kt @@ -4,45 +4,41 @@ import com.app.ralaunch.core.logging.AppLog import com.app.ralaunch.R import com.app.ralaunch.RaLaunchApp import com.app.ralaunch.core.common.util.TemporaryFileAcquirer -import com.app.ralaunch.core.extractor.BasicSevenZipExtractor -import com.app.ralaunch.core.extractor.ExtractorCollection +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?) { @@ -52,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") AppLog.d(TAG, "Successfully parsed header - offset: ${shFile.offset}, filesize: ${shFile.filesize}") - FileInputStream(sourcePath.toFile()).use { fis -> - AppLog.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( @@ -80,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 - AppLog.d(TAG, "Extracting mojosetup.tar.gz to $mojosetupPath") + Log.d(TAG, "Extracting mojosetup.tar.gz to $mojosetupFile") srcChannel.transferTo(shFile.offset, shFile.filesize, mojosetupChannel) } @@ -94,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 - AppLog.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), @@ -113,10 +109,10 @@ class GogShFileExtractor( ) AppLog.d(TAG, "Extraction from MakeSelf SH file completed successfully") - // 解压 game_data.zip - AppLog.d(TAG, "Trying to extract game_data.zip...") - val gdzf = GameDataZipFile.parse(gameDataPath) - ?: throw IOException("解析 game_data.zip 失败") + // Decompress game_data.zip + Log.d(TAG, "Trying to extract 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), @@ -124,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) @@ -144,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() @@ -162,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) @@ -181,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) AppLog.d(TAG, "Read $bytesRead bytes from header") headerContent = String(headerBuffer, 0, bytesRead, StandardCharsets.UTF_8) @@ -288,7 +284,7 @@ class GogShFileExtractor( } /** - * 游戏数据 ZIP 文件解析器 + * Game Data ZIP File Parser */ data class GameDataZipFile( var id: String? = null, @@ -309,9 +305,9 @@ 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 { - AppLog.e(TAG, "MakeSelf SH file is null") + fun parseFromGogShFile(file: File): GameDataZipFile? { + val shFile = MakeSelfShFile.parse(file) ?: run { + Log.e(TAG, "MakeSelf SH file is null") return null } @@ -319,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) @@ -335,14 +331,14 @@ class GogShFileExtractor( parse(tempZipFile) } } catch (ex: Exception) { - AppLog.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) @@ -373,7 +369,7 @@ class GogShFileExtractor( private fun getFileContent(zip: ZipFile, entryPath: String): String? { val entry = zip.getEntry(entryPath) if (entry == null) { - AppLog.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/model/GameItem.kt b/app/src/main/java/com/app/ralaunch/core/model/GameItem.kt index bdacee39..02255897 100644 --- a/app/src/main/java/com/app/ralaunch/core/model/GameItem.kt +++ b/app/src/main/java/com/app/ralaunch/core/model/GameItem.kt @@ -3,26 +3,8 @@ package com.app.ralaunch.core.model import com.app.ralaunch.core.di.contract.IGameRepositoryServiceV3 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,使用相对路径存储。 - * 通过 gameRepositoryParent 延迟解析绝对路径。 - * - * @property id 唯一安装标识符,用作存储目录名(游戏名+随机哈希) - * @property displayedName 显示名称 - * @property displayedDescription 描述信息 - * @property gameId 游戏类型标识符(如 "celeste", "terraria") - * @property gameExePathRelative 游戏主程序相对路径(相对于游戏存储根目录) - * @property iconPathRelative 图标相对路径(相对于游戏存储根目录),可为空 - * @property modLoaderEnabled 是否启用 ModLoader - * @property rendererOverride 渲染器覆盖(null 表示跟随全局设置) - * @property dotNetRuntimeVersionOverride .NET 运行时版本覆盖(null 表示跟随全局设置) - * @property gameEnvVars 游戏环境变量(null 值表示在启动前 unset 对应变量) - * @property gameRepositoryParent 父仓库实例引用(非序列化,反序列化后需手动设置) - */ @Serializable data class GameItem( val id: String, @@ -39,52 +21,30 @@ data class GameItem( @Transient var gameRepositoryParent: IGameRepositoryServiceV3? = null ) { - /** - * 游戏存储目录名称(相对于全局 games 目录) - * - * 等同于 [id],返回子目录名称。 - * 例: 全局目录为 `/data/games/`,此属性返回 `"celeste_a1b2c3d4"`, - * 则存储根目录为 `/data/games/celeste_a1b2c3d4/` - */ + @Transient val storageRootPathRelative: String get() = id - /** - * 游戏存储根目录的绝对路径 - * - * 通过全局存储目录与 [id] 拼接得到。 - * 若 [gameRepositoryParent] 未设置则返回 null。 - * - * 例: `/data/games/celeste_a1b2c3d4/` - */ + @Transient val storageRootPathFull: String? - get() = gameRepositoryParent?.getGameGlobalStorageDirFull()?.let { - Path(it).resolve(id).toString() + get() = gameListStorageParent?.getGameGlobalStorageDirFull()?.let { + // 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 + } } } 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 b7871c69..3d39063a 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 @@ -14,12 +14,9 @@ import com.app.ralaunch.core.platform.runtime.GameLauncher 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.logging.AppLog -import java.nio.file.Paths +import com.app.ralaunch.core.common.util.AppLogger +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,14 +132,19 @@ 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() - AppLog.i(TAG, "Game: $gameId, Applicable patches: ${patches.size}") + + 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) { AppLog.e(TAG, "Launch failed: ${e.message}", e) @@ -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 - AppLog.i(TAG, "stdin 管道已建立 (native write_fd=$writeFd)") + AppLogger.info(TAG, "stdin pipe ready (native write_fd=$writeFd)") } else { - AppLog.w(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) { - AppLog.w(TAG, "stdin 管道未就绪,忽略输入: $input") + AppLogger.warn(TAG, "stdin pipe not ready, ignoring: $input") return } val result = NativeMethods.writeStdin(input) if (result >= 0) { AppLog.i(TAG, "stdin << $input ($result bytes)") } else { - AppLog.e(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 2ed24b9d..550dbe60 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 @@ -20,17 +20,8 @@ import com.app.ralaunch.core.logging.AppLog import java.io.File import java.io.FileNotFoundException import java.io.IOException +// XOA: 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/ 设备加密数据目录(如果存在) - */ class RaLaunchDocumentsProvider : DocumentsProvider() { private lateinit var mPackageName: String @@ -45,18 +36,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 +88,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { val parent = getFileForDocId(docId) if (parent == null) { - // 根目录:显示所有主要目录 includeFile(result, "$docId/data", mDataDir) mAndroidDataDir?.takeIf { it.exists() }?.let { @@ -173,7 +160,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 +167,8 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { return } - // 使用 ralib 的 FileUtils 删除目录 - val success = FileUtils.deleteDirectoryRecursively(file.toPath()) + // 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 +284,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 +302,6 @@ class RaLaunchDocumentsProvider : DocumentsProvider() { return } - // 计算标志位 var flags = 0 if (targetFile.isDirectory) { if (targetFile.canWrite()) { @@ -334,14 +317,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 +339,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 +347,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 +362,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 +385,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 +396,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) @@ -488,25 +458,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/runtime/AssemblyPatcher.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/AssemblyPatcher.kt index cac11b21..42dde8a7 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() - AppLog.i(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?) { - AppLog.d(TAG, "解压中: $message (${(progress * 100).toInt()}%)") + AppLogger.debug(TAG, "Extracting: $message (${(progress * 100).toInt()}%)") } override fun onComplete(message: String, state: HashMap?) { - AppLog.i(TAG, "MonoMod 解压完成") + AppLogger.info(TAG, "MonoMod extraction complete") } override fun onError(message: String, ex: Exception?, state: HashMap?) { - AppLog.e(TAG, "解压错误: $message", ex) + AppLogger.error(TAG, "Extraction error: $message", ex) } } ).extract() - AppLog.i(TAG, "MonoMod 已解压到 $targetDir") + AppLogger.info(TAG, "MonoMod extracted to $targetDir") true } } catch (e: Exception) { - AppLog.e(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) AppLog.w(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) AppLog.d(TAG, "已替换: $assemblyName") + if (verboseLog) AppLogger.debug(TAG, "Replaced: $assemblyName") patchedCount++ } } } - if (verboseLog) AppLog.i(TAG, "已应用 MonoMod 补丁,替换了 $patchedCount 个文件") + if (verboseLog) AppLogger.info(TAG, "MonoMod patches applied, replaced $patchedCount files") patchedCount } catch (e: Exception) { - AppLog.e(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) { - AppLog.w(TAG, "MonoMod 目录不存在: $monoModPath") + AppLogger.warn(TAG, "MonoMod directory does not exist: $monoModDir") return assemblies } val dllFiles = findDllFiles(monoModDir) - AppLog.d(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) { - AppLog.w(TAG, "读取 DLL 失败: ${dllFile.name}", e) + AppLogger.warn(TAG, "Failed to read DLL: ${dllFile.name}", e) } } } catch (e: Exception) { - AppLog.e(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) { - AppLog.e(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 e97ffb19..720e0892 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 AppLog.i(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,68 +39,28 @@ object GameLauncher { System.loadLibrary("openal32") System.loadLibrary("lwjgl_lz4") System.loadLibrary("SkiaSharp") - } catch (e: UnsatisfiedLinkError) { AppLog.e(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 } - /** - * 启动 .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 dotNetRuntimeVersionOverride 可选的 .NET 运行时版本覆盖(null 表示使用全局设置) - * Optional .NET runtime version 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) - */ + private fun initializeSDLJNI(context: Context) { + if (isSDLJNIInitialized) return + try { + AppLogger.info(TAG, "正在初始化 SDL JNI 环境 / Initializing SDL JNI environment...") + SDL.setupJNI() + SDL.setContext(context) + isSDLJNIInitialized = true + AppLogger.info(TAG, "SDL JNI 初始化成功 / SDL JNI initialized successfully") + } catch (e: Exception) { + AppLogger.warn(TAG, "SDL JNI 初始化失败 / Failed to initialize SDL JNI: ${e.message}") + } + } + fun launchDotNetAssembly( assemblyPath: String, args: Array, @@ -172,38 +70,24 @@ object GameLauncher { gameEnvVars: Map = emptyMap() ): Int { try { - AppLog.i(TAG, "=== 开始启动 .NET 程序集 / Starting .NET Assembly Launch ===") - AppLog.i(TAG, "程序集路径 / Assembly path: $assemblyPath") - AppLog.i(TAG, "启动参数 / Arguments: ${args.joinToString(", ")}") - AppLog.i(TAG, "启用补丁数 / Enabled patches: ${enabledPatches?.size ?: 0}") + AppLogger.info(TAG, "=== 开始启动 .NET 程序集 / Starting .NET Assembly Launch ===") + AppLogger.info(TAG, "程序集路径 / Assembly path: $assemblyPath") - // 步骤1:验证程序集文件存在 - // Step 1: Verify assembly file exists - if (!Path(assemblyPath).exists()) { - AppLog.e(TAG, "程序集文件不存在 / Assembly file does not exist: $assemblyPath") + if (!File(assemblyPath).exists()) { + AppLogger.error(TAG, "程序集文件不存在 / Assembly file does not exist: $assemblyPath") return -1 } - AppLog.d(TAG, "程序集文件验证通过 / Assembly file exists: OK") - // 步骤2:设置基础环境变量 - // Step 2: Set basic environment variables - AppLog.d(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() - AppLog.d(TAG, "切换工作目录 / Changing working directory to: $workingDir") + val workingDir = File(assemblyPath).parent ?: "" + AppLogger.debug(TAG, "切换工作目录 / Changing working directory to: $workingDir") NativeMethods.chdir(workingDir) - AppLog.d(TAG, "工作目录切换完成 / Working directory changed: OK") - // 步骤4:准备数据目录 - // Step 4: Prepare data directory - AppLog.d(TAG, "准备数据目录 / Preparing data directory...") val dataDir = prepareDataDirectory(assemblyPath) val cacheDir = appContext.cacheDir.absolutePath AppLog.i(TAG, "数据目录 / Data directory: $dataDir") @@ -215,55 +99,21 @@ object GameLauncher { "XDG_CACHE_HOME" to cacheDir, "TMPDIR" to cacheDir ) - AppLog.d(TAG, "XDG 环境变量设置完成 / XDG environment variables set: OK") - // 步骤5:应用用户设置 - // Step 5: Apply user settings val settings = SettingsAccess - AppLog.d(TAG, "应用用户设置 / Applying settings configuration...") - AppLog.d(TAG, " - 大核亲和性 / Big core affinity: ${settings.setThreadAffinityToBigCoreEnabled}") - AppLog.d(TAG, " - 多点触控 / Touch multitouch: ${settings.isTouchMultitouchEnabled}") - - // 步骤6:配置启动钩子(补丁) - // Step 6: Configure startup hooks (patches) val startupHooks = if (enabledPatches != null && enabledPatches.isNotEmpty()) PatchManager.constructStartupHooksEnvVar(enabledPatches) else null - if (startupHooks != null) { - AppLog.i(TAG, "已配置 ${enabledPatches!!.size} 个补丁的启动钩子 / DOTNET_STARTUP_HOOKS configured with ${enabledPatches.size} patch(es)") - AppLog.d(TAG, "DOTNET_STARTUP_HOOKS 值 / value: $startupHooks") - val hookCount = startupHooks.split(":").filter { it.isNotEmpty() }.size - AppLog.d(TAG, "实际钩子数量 / Actual hook count: $hookCount") - } else { - AppLog.d(TAG, "未配置启动钩子 / No startup hooks configured") - } - - // 步骤7:设置 MonoMod 路径(供补丁使用) - // Step 7: Set MonoMod path (for patches) val monoModPath = AssemblyPatcher.getMonoModInstallPath().toString() - AppLog.i(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", - - // 音频配置 - // Audio configuration + "RALCORE_MOUSE_RIGHT_STICK" to if (settings.isMouseRightStickEnabled) "1" else null, "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", @@ -286,31 +136,17 @@ object GameLauncher { "RAL_GL_MAP_RATIO" to null, "RAL_GL_MAP_ENABLED" to null, ) - AppLog.d(TAG, "游戏设置环境变量配置完成 / Game settings environment variables set: OK") - // 步骤8:配置渲染器 - // Step 8: Configure renderer - AppLog.d(TAG, "配置渲染器环境 / Applying renderer environment...") RendererEnvironmentConfigurator.apply( context = appContext, rendererOverride = rendererOverride ) - AppLog.d(TAG, "渲染器环境配置完成 / Renderer environment applied: OK") - // 步骤9:设置线程亲和性 - // Step 9: Set thread affinity if (settings.setThreadAffinityToBigCoreEnabled) { - AppLog.d(TAG, "设置线程亲和性到大核 / Setting thread affinity to big cores...") - val result = ThreadAffinityManager.setThreadAffinityToBigCores() - AppLog.d(TAG, "线程亲和性设置完成 / Thread affinity to big cores set: Result=$result") - } else { - AppLog.d(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()) { - AppLog.d(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, @@ -328,134 +164,54 @@ object GameLauncher { availableInterpolations = availableInterpolations ) EnvVarsManager.quickSetEnvVars(resolvedGameEnvVars) - AppLog.d(TAG, "游戏环境变量应用完成 / Per-game env vars applied: OK") } - // 步骤11:启动 .NET 运行时 - // Step 11: Launch .NET runtime - AppLog.i(TAG, "通过 hostfxr 启动 .NET 运行时 / Launching .NET runtime with hostfxr...") - val result = DotNetLauncher.hostfxrLaunch( - assemblyPath = assemblyPath, - args = args, - dotNetRuntimeVersionOverride = dotNetRuntimeVersionOverride - ) - - AppLog.i(TAG, "=== .NET 程序集启动完成 / .NET Assembly Launch Completed ===") - AppLog.i(TAG, "退出代码 / Exit code: $result") - + val result = DotNetLauncher.hostfxrLaunch(assemblyPath, args) + AppLogger.info(TAG, "退出代码 / Exit code: $result") return result + } catch (e: Exception) { - AppLog.e(TAG, "启动程序集失败 / Failed to launch assembly: $assemblyPath", e) + AppLogger.error(TAG, "启动程序集失败 / Failed to launch assembly: $assemblyPath", e) 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 { - AppLog.i(TAG, "=== 收到新进程启动请求 / launchNewDotNetProcess called ===") - AppLog.i(TAG, "程序集 / Assembly: $assemblyPath") - AppLog.i(TAG, "标题 / Title: $title") - AppLog.i(TAG, "游戏ID / Game ID: $gameId") - AppLog.i(TAG, "参数 / Arguments: ${args.joinToString(", ")}") - + return try { ProcessLauncherService.launch(assemblyPath, args, title, gameId) - - return 0 + 0 } catch (e: Exception) { - AppLog.e(TAG, "启动新 .NET 进程失败 / Failed to launch new .NET process", e) - return -1 + AppLogger.error(TAG, "启动新 .NET 进程失败 / Failed to launch new .NET process", e) + -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 - AppLog.d(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() - - AppLog.d(TAG, "目标数据目录 / Target data directory: $defaultDataDirPath") + val defaultDataDir = File( + Environment.getExternalStorageDirectory(), + DEFAULT_DATA_DIR_NAME + ) - // 创建目录(如果不存在) - // Create directory if it doesn't exist - if (!defaultDataDirPath.exists()) { - AppLog.d(TAG, "创建数据目录 / Creating data directory: $defaultDataDirPath") - defaultDataDirPath.createDirectories() - AppLog.d(TAG, "数据目录创建成功 / Data directory created: OK") - } else { - AppLog.d(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()) { - AppLog.d(TAG, "创建 .nomedia 文件 / Creating .nomedia file: $nomediaFilePath") - nomediaFilePath.createFile() - AppLog.d(TAG, ".nomedia 文件创建成功 / .nomedia file created: OK") - } else { - AppLog.d(TAG, ".nomedia 文件已存在 / .nomedia file already exists") + val nomediaFile = File(defaultDataDir, ".nomedia") + if (!nomediaFile.exists()) { + nomediaFile.createNewFile() } - finalDataDir = defaultDataDirPath - AppLog.i(TAG, "使用默认数据目录 / Using default data directory: $finalDataDir") + finalDataDir = defaultDataDir.absolutePath + AppLogger.info(TAG, "使用默认数据目录 / Using default data directory: $finalDataDir") + } catch (e: Exception) { - // 无法访问外部存储,使用程序集目录作为回退 - // Cannot access external storage, use assembly directory as fallback - AppLog.w(TAG, "无法访问默认数据目录,使用程序集目录 / Failed to access default data directory, using assembly directory instead.", e) - AppLog.w(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/RendererEnvironmentConfigurator.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererEnvironmentConfigurator.kt index b0984e80..7b92c120 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererEnvironmentConfigurator.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/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) -> - AppLog.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 -> - AppLog.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 -> AppLog.i(TAG, "Using per-game renderer override: ${RendererRegistry.normalizeRendererId(rawOverride)}") } @@ -66,7 +61,8 @@ object RendererEnvironmentConfigurator { loadRendererLibraries(context, renderer) applyFna3dEnvironment(renderer) - AppLog.i(TAG, "Renderer environment applied successfully for: $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() @@ -191,25 +192,27 @@ object RendererEnvironmentConfigurator { AndroidRendererRegistry.ID_GL4ES -> AppLog.i(TAG, "OpenGL Profile: Desktop OpenGL 2.1 Compatibility Profile") AndroidRendererRegistry.ID_ZINK -> - AppLog.i(TAG, "OpenGL Profile: Desktop OpenGL 4.3 (Mesa Zink over Vulkan)") + Log.i(TAG, "OpenGL Profile: Desktop OpenGL 4.3 (Mesa Zink over Vulkan)") + RendererRegistry.ID_ANGLE, + RendererRegistry.ID_GL4ES_ANGLE, + RendererRegistry.ID_NATIVE, + RendererRegistry.ID_MOBILEGLUES -> + Log.i(TAG, "OpenGL Profile: OpenGL ES 3.0") else -> - AppLog.i(TAG, "OpenGL Profile: OpenGL ES 3.0") + 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 - ) -> - AppLog.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" -> AppLog.i(TAG, "Map Buffer Range: Disabled (via settings)") else -> AppLog.i(TAG, "Map Buffer Range: Enabled by default") } - AppLog.i(TAG, "VSync: Forced ON") - AppLog.i(TAG, "===========================") + Log.i(TAG, "VSync: Controlled by runtime/settings") + Log.i(TAG, "===========================") } } diff --git a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererLoader.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererLoader.kt index eae002e4..62035e88 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererLoader.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererLoader.kt @@ -2,16 +2,13 @@ package com.app.ralaunch.core.platform.runtime import android.content.Context import android.system.Os +import com.app.ralaunch.core.common.util.AppLogger import com.app.ralaunch.core.platform.runtime.EnvVarsManager -import com.app.ralaunch.core.logging.AppLog -import com.app.ralaunch.core.platform.runtime.AndroidRendererRegistry -import com.app.ralaunch.core.platform.runtime.RendererRegistry +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)) { - AppLog.e(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) { - AppLog.e(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) - AppLog.i(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) - AppLog.i(TAG, "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) { AppLog.e(TAG, "Renderer loading failed: ${e.message}", e) @@ -71,10 +140,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/platform/runtime/RendererRegistry.kt b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererRegistry.kt index 727ded25..d4b416ab 100644 --- a/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererRegistry.kt +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RendererRegistry.kt @@ -7,7 +7,9 @@ import android.os.Environment import com.app.ralaunch.R import com.app.ralaunch.core.common.util.getAppString 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 import org.koin.core.context.GlobalContext object RendererRegistry { @@ -59,12 +61,14 @@ 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" @@ -87,8 +91,11 @@ 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 } ) @@ -102,6 +109,7 @@ 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" } @@ -177,37 +185,31 @@ 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 @@ -238,8 +240,16 @@ 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 @@ -251,6 +261,37 @@ 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/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 new file mode 100644 index 00000000..86ef3c13 --- /dev/null +++ b/app/src/main/java/com/app/ralaunch/core/platform/runtime/RuntimeLibraryLoader.kt @@ -0,0 +1,414 @@ +package com.app.ralaunch.core.platform.runtime + +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 +// ... Removed Apache TarArchiveInputStream to prevent crash ... +import org.tukaani.xz.XZInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream + +/** + * 运行时库加载器 + * + * 负责从 assets 中解压大型 native 库到私有目录。 + * 这些库在编译时不链接到主程序,而是在运行时按需加载。 + * + * 支持的库: + * - libmobileglues.so (8 MB) - GLES 转换层 + * - libSkiaSharp.so (7 MB) - Skia 图形库 + * - libGLESv2_angle.so (5 MB) - ANGLE OpenGL ES + * - libGL_gl4es.so (4 MB) - GL4ES OpenGL + * - lib7-Zip-JBinding.so (3 MB) - 7-Zip 解压库 + */ +object RuntimeLibraryLoader { + + private const val TAG = "RuntimeLibLoader" + + // 运行时库存放目录 + private const val RUNTIME_LIBS_DIR = "runtime_libs" + + // assets 中的压缩包名 + private const val RUNTIME_LIBS_ARCHIVE = "runtime_libs.tar.xz" + + // 版本标记文件,用于判断是否需要重新解压 + private const val VERSION_FILE = ".version" + + // 已加载的库集合 + private val loadedLibraries = mutableSetOf() + + /** + * 运行时库枚举 + */ + enum class RuntimeLib(val fileName: String, val description: String) { + MOBILEGLUES("libmobileglues.so", "MobileGlues 翻译层"), + SKIASHARP("libSkiaSharp.so", "Skia 图形库"), + ANGLE_EGL("libEGL_angle.so", "ANGLE EGL"), + ANGLE_GLES("libGLESv2_angle.so", "ANGLE OpenGL ES"), + GL4ES_EGL("libEGL_gl4es.so", "GL4ES EGL"), + GL4ES("libGL_gl4es.so", "GL4ES OpenGL") + // 7-Zip 保留在 APK 中,通过 System.loadLibrary 加载 + } + + /** + * 必须存在的关键库文件列表 + */ + private val REQUIRED_LIBS = listOf( + "libGL_gl4es.so", + "libEGL_gl4es.so" + ) + + /** + * 检查运行时库是否已解压 + */ + fun isExtracted(context: Context): Boolean { + val runtimeDir = File(context.filesDir, RUNTIME_LIBS_DIR) + val versionFile = File(runtimeDir, VERSION_FILE) + + if (!runtimeDir.exists() || !versionFile.exists()) { + AppLogger.info(TAG, "Runtime libs not extracted: directory or version file missing") + return false + } + + // 检查关键库文件是否存在 + for (libName in REQUIRED_LIBS) { + val libFile = File(runtimeDir, libName) + if (!libFile.exists() || libFile.length() == 0L) { + AppLogger.warn(TAG, "Required library missing or empty: $libName") + return false + } + } + + // 检查版本是否匹配 + val currentVersion = getAssetVersion(context) + val extractedVersion = versionFile.readText().trim() + + if (currentVersion != extractedVersion) { + AppLogger.info(TAG, "Version mismatch: current=$currentVersion, extracted=$extractedVersion") + return false + } + + return true + } + + /** + * 获取 assets 中压缩包的版本 + * 使用 AssetFileDescriptor 获取真实文件大小 + */ + private fun getAssetVersion(context: Context): String { + return try { + context.assets.openFd(RUNTIME_LIBS_ARCHIVE).use { afd -> + val size = afd.length + "v2_$size" // v2 表示新的版本计算方式 + } + } catch (e: Exception) { + // openFd 可能对压缩的 assets 失败,回退到读取方式 + try { + context.assets.open(RUNTIME_LIBS_ARCHIVE).use { stream -> + var size = 0L + val buffer = ByteArray(8192) + var read: Int + while (stream.read(buffer).also { read = it } != -1) { + size += read + } + "v2_$size" + } + } catch (e2: Exception) { + AppLogger.error(TAG, "Failed to get asset version: ${e2.message}") + "unknown" + } + } + } + + /** + * 获取运行时库目录 + */ + fun getRuntimeLibsDir(context: Context): File { + return File(context.filesDir, RUNTIME_LIBS_DIR) + } + + /** + * 获取指定库的路径 + */ + fun getLibraryPath(context: Context, libName: String): String { + return File(getRuntimeLibsDir(context), libName).absolutePath + } + + /** + * 解压运行时库 + * + * @param context Android Context + * @param progressCallback 进度回调 (0-100) + */ + suspend fun extractRuntimeLibs( + context: Context, + progressCallback: ((Int, String) -> Unit)? = null + ): Boolean = withContext(Dispatchers.IO) { + val runtimeDir = File(context.filesDir, RUNTIME_LIBS_DIR) + + try { + // 检查 assets 中是否存在压缩包 + val assetList = context.assets.list("") ?: emptyArray() + if (RUNTIME_LIBS_ARCHIVE !in assetList) { + AppLogger.warn(TAG, "Runtime libs archive not found in assets: $RUNTIME_LIBS_ARCHIVE") + AppLogger.warn(TAG, "This is expected if libs are bundled in APK directly") + return@withContext true // 如果没有压缩包,认为是直接打包模式 + } + + AppLogger.info(TAG, "Extracting runtime libraries...") + progressCallback?.invoke( + 0, + RaLaunchApp.getInstance().getString(R.string.runtime_extract_prepare) + ) + + // 清理旧目录 + if (runtimeDir.exists()) { + runtimeDir.deleteRecursively() + } + runtimeDir.mkdirs() + + // 解压 tar.xz + context.assets.open(RUNTIME_LIBS_ARCHIVE).use { assetStream -> + val bufferedStream = BufferedInputStream(assetStream, 1024 * 1024) + val xzStream = XZInputStream(bufferedStream) + + // ... Use our custom MiniTarReader instead of Apache ... + val tarReader = ArchiveExtractor.MiniTarReader(xzStream) + + var extractedCount = 0 + val buffer = ByteArray(8192) // ... buffer for custom copy ... + + while (true) { + val entry = tarReader.nextEntry() ?: break + val outputFile = File(runtimeDir, entry.name) + + if (entry.isDirectory) { + outputFile.mkdirs() + } else { + outputFile.parentFile?.mkdirs() + + FileOutputStream(outputFile).use { fos -> + // ... Custom write loop ... + while (true) { + val read = tarReader.readData(buffer) + if (read <= 0) break + fos.write(buffer, 0, read) + } + } + + // 设置库文件权限(可读 + 可执行) + if (entry.name.endsWith(".so")) { + outputFile.setReadable(true, false) // 所有用户可读 + outputFile.setExecutable(true, false) // 所有用户可执行 + AppLogger.debug(TAG, "Set permissions for: ${entry.name}") + } + + extractedCount++ + val progress = (extractedCount * 100) / 10 // 假设约10个文件 + progressCallback?.invoke( + minOf(progress, 95), + RaLaunchApp.getInstance().getString( + R.string.runtime_extract_entry, + entry.name + ) + ) + + AppLogger.debug(TAG, "Extracted: ${entry.name}") + } + } + } + + // 验证关键库是否都已解压 + val missingLibs = REQUIRED_LIBS.filter { libName -> + val libFile = File(runtimeDir, libName) + !libFile.exists() || libFile.length() == 0L + } + + if (missingLibs.isNotEmpty()) { + AppLogger.error(TAG, "Missing required libraries after extraction: $missingLibs") + progressCallback?.invoke( + -1, + RaLaunchApp.getInstance().getString( + R.string.runtime_extract_incomplete_missing, + missingLibs.joinToString() + ) + ) + return@withContext false + } + + // 验证成功后写入版本标记 + val versionFile = File(runtimeDir, VERSION_FILE) + versionFile.writeText(getAssetVersion(context)) + + // 列出所有已解压的库 + val extractedLibs = listExtractedLibraries(context) + AppLogger.info(TAG, "Runtime libraries extracted: ${extractedLibs.joinToString()}") + + progressCallback?.invoke( + 100, + RaLaunchApp.getInstance().getString(R.string.extract_complete) + ) + AppLogger.info(TAG, "Runtime libraries extracted to: ${runtimeDir.absolutePath}") + + true + } catch (e: Exception) { + AppLogger.error(TAG, "Failed to extract runtime libs", e) + progressCallback?.invoke( + -1, + RaLaunchApp.getInstance().getString( + R.string.runtime_extract_failed_with_reason, + e.message ?: "" + ) + ) + // 清理可能的不完整解压 + try { + runtimeDir.deleteRecursively() + } catch (_: Exception) {} + false + } + } + + /** + * 强制重新解压运行时库 + * 删除版本文件后重新解压 + */ + suspend fun forceReExtract( + context: Context, + progressCallback: ((Int, String) -> Unit)? = null + ): Boolean { + val runtimeDir = File(context.filesDir, RUNTIME_LIBS_DIR) + + // 删除整个目录强制重新解压 + if (runtimeDir.exists()) { + runtimeDir.deleteRecursively() + } + + return extractRuntimeLibs(context, progressCallback) + } + + /** + * 列出已解压的运行时库 + */ + fun listExtractedLibraries(context: Context): List { + val runtimeDir = getRuntimeLibsDir(context) + if (!runtimeDir.exists()) return emptyList() + + return runtimeDir.listFiles() + ?.filter { it.isFile && it.name.endsWith(".so") } + ?.map { it.name } + ?: emptyList() + } + + /** + * 加载指定的运行时库 + * + * @param context Android Context + * @param lib 要加载的库 + * @return true 如果加载成功 + */ + fun loadLibrary(context: Context, lib: RuntimeLib): Boolean { + if (loadedLibraries.contains(lib.fileName)) { + AppLogger.debug(TAG, "${lib.fileName} already loaded") + return true + } + + val libPath = getLibraryPath(context, lib.fileName) + val libFile = File(libPath) + + if (!libFile.exists()) { + AppLogger.error(TAG, "${lib.fileName} not found at: $libPath") + return false + } + + return try { + AppLogger.info(TAG, "Loading ${lib.description}: ${lib.fileName}") + System.load(libPath) + loadedLibraries.add(lib.fileName) + AppLogger.info(TAG, "${lib.fileName} loaded successfully") + true + } catch (e: UnsatisfiedLinkError) { + AppLogger.error(TAG, "Failed to load ${lib.fileName}", e) + false + } + } + + /** + * 检查库是否已加载 + */ + fun isLibraryLoaded(lib: RuntimeLib): Boolean { + return loadedLibraries.contains(lib.fileName) + } + + /** + * 加载所有渲染器相关的库 + * 用于游戏启动前预加载 + */ + fun loadRendererLibraries(context: Context, useAngle: Boolean): Boolean { + var success = true + + // GL4ES 通常需要 + if (!loadLibrary(context, RuntimeLib.GL4ES)) { + AppLogger.warn(TAG, "GL4ES load failed, may use fallback") + } + + // ANGLE (可选) - 需要同时加载 EGL 和 GLES + if (useAngle) { + if (!loadLibrary(context, RuntimeLib.ANGLE_EGL)) { + AppLogger.warn(TAG, "ANGLE EGL load failed") + } + if (!loadLibrary(context, RuntimeLib.ANGLE_GLES)) { + AppLogger.warn(TAG, "ANGLE GLES load failed") + success = false + } + } + + return success + } + + /** + * 加载 SkiaSharp 库 + * 用于需要 Skia 渲染的游戏(如某些 .NET 游戏) + */ + fun loadSkiaSharp(context: Context): Boolean { + return loadLibrary(context, RuntimeLib.SKIASHARP) + } + + /** + * 加载 7-Zip 库 + * 用于解压游戏文件 + * 注意:7-Zip 库现在保留在 APK 中,此方法作为备选从 runtime_libs 加载 + */ + fun load7Zip(context: Context): Boolean { + // 尝试从 runtime_libs 加载(作为备选) + val libPath = File(getRuntimeLibsDir(context), "lib7-Zip-JBinding.so") + if (libPath.exists()) { + return try { + System.load(libPath.absolutePath) + loadedLibraries.add("7-Zip-JBinding") + AppLogger.info(TAG, "7-Zip library loaded from runtime_libs") + true + } catch (e: UnsatisfiedLinkError) { + AppLogger.error(TAG, "Failed to load 7-Zip from runtime_libs: ${e.message}") + false + } + } + AppLogger.error(TAG, "lib7-Zip-JBinding.so not found at: ${libPath.absolutePath}") + return false + } + + /** + * 获取库加载状态摘要 + */ + fun getLoadedLibrariesSummary(): String { + return if (loadedLibraries.isEmpty()) { + "No runtime libraries loaded" + } else { + "Loaded: ${loadedLibraries.joinToString(", ")}" + } + } +} 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 97394ba0..c37fbc1f 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 com.app.ralaunch.core.logging.AppLog 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) { AppLog.e(TAG, "❌ 加载 .NET Native 库失败", e) @@ -72,8 +73,9 @@ object DotNetNativeLibraryLoader { private fun loadLibrary(basePath: String, libName: String, required: Boolean) { try { - val fullPath = Paths.get(basePath, libName).toString() - AppLog.i(TAG, "正在加载: $libName") + // ... REPLACED: Paths.get(...) with standard File constructors ... + val fullPath = File(basePath, libName).absolutePath + Log.i(TAG, "正在加载: $libName") System.load(fullPath) AppLog.i(TAG, " ✓ $libName 加载成功") } catch (e: UnsatisfiedLinkError) { @@ -103,8 +105,10 @@ object DotNetNativeLibraryLoader { } val version = versions[0] - AppLog.i(TAG, "检测到运行时版本: $version") - Paths.get(dotnetRoot, "shared/Microsoft.NETCore.App/$version").toString() + Log.i(TAG, "检测到运行时版本: $version") + + // ... REPLACED: Paths.get(...) with standard File constructors ... + File(runtimeDir, version).absolutePath } catch (e: Exception) { AppLog.e(TAG, "查找运行时路径失败", e) null 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 a8e2d2fb..8acf44b4 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 @@ -17,10 +19,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** - * 游戏界面 Presenter - * 处理游戏启动和崩溃报告相关的业务逻辑 - */ class GamePresenter : GameContract.Presenter { companion object { @@ -43,7 +41,6 @@ class GamePresenter : GameContract.Presenter { override fun launchGame(): Int { val view = this.view ?: return -1 - // 重置 GameLauncher 初始化状态,确保每次启动都重新初始化 GameLauncher.resetInitializationState() return try { @@ -120,8 +117,9 @@ class GamePresenter : GameContract.Presenter { } catch (_: Exception) { null } + val enabledPatches = patchManager - ?.getApplicableAndEnabledPatches(game.gameId, assemblyFile.toPath()) + ?.getApplicableAndEnabledPatches(game.gameId, assemblyFile) ?: emptyList() return launchAssembly( @@ -160,8 +158,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() } @@ -213,7 +212,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) @@ -340,23 +339,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 @@ -365,10 +356,8 @@ class GamePresenter : GameContract.Presenter { val allLogs = reader.readLines() process.destroy() - // 过滤和整理日志 val filteredLogs = allLogs .filter { line -> - // 过滤掉空行和无关的系统日志 line.isNotBlank() && !line.contains("GC_") && !line.contains("Choreographer") && @@ -377,7 +366,6 @@ class GamePresenter : GameContract.Presenter { .takeLast(MAX_LOG_LINES) .joinToString("\n") - // 如果没有找到有用的日志,尝试获取所有错误级别的日志 if (filteredLogs.isEmpty()) { return getErrorLevelLogs() } @@ -394,9 +382,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/game/ui/legacy/GameActivity.kt b/app/src/main/java/com/app/ralaunch/feature/game/ui/legacy/GameActivity.kt index e896e5c1..a4680255 100644 --- a/app/src/main/java/com/app/ralaunch/feature/game/ui/legacy/GameActivity.kt +++ b/app/src/main/java/com/app/ralaunch/feature/game/ui/legacy/GameActivity.kt @@ -29,11 +29,9 @@ import com.app.ralaunch.core.common.util.LocaleManager import com.app.ralaunch.core.error.ErrorHandler import com.app.ralaunch.core.model.ThemeMode 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 { @@ -51,10 +49,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) @@ -63,12 +58,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 { @@ -81,57 +72,59 @@ class GameActivity : SDLActivity(), GameContract.View { } @JvmStatic - fun launch( - context: Context, - gameStorageId: String - ) { - val intent = createLaunchIntent( - context = context, - gameStorageId = gameStorageId - ).apply { - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - } - context.startActivity(intent) + 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() ) { - val intent = createLaunchIntent( - context = context, - gameExePath = gameExePath, - gameArgs = gameArgs, - gameId = gameId, - gameRendererOverride = gameRendererOverride, - gameEnvVars = gameEnvVars - ).apply { - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - } - context.startActivity(intent) + context.startActivity(createLaunchIntent(context, gameExePath, gameArgs, gameId, gameRendererOverride, gameEnvVars)) (context as? Activity)?.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } + + @JvmStatic + fun sendTextToGame(text: String) { GameImeHelper.sendTextToGame(text) } + + @JvmStatic + fun sendBackspace() { GameImeHelper.sendBackspaceToGame() } + + @JvmStatic + fun enableSDLTextInputForIME() { GameImeHelper.enableSDLTextInputForIME() } + + @JvmStatic + fun disableSDLTextInput() { GameImeHelper.disableSDLTextInput() } + + @JvmStatic + fun onGameExitWithMessage(exitCode: Int, errorMessage: String?) { + instance?.presenter?.onGameExit(exitCode, errorMessage) + } + + @JvmStatic + fun nativeSetTouchDataBridge(count: Int, x: FloatArray, y: FloatArray, screenWidth: Int, screenHeight: Int) { + nativeSetTouchData(count, x, y, screenWidth, screenHeight) + } + + @JvmStatic + fun nativeClearTouchDataBridge() { nativeClearTouchData() } + + @JvmStatic + private external fun nativeSetTouchData(count: Int, x: FloatArray, y: FloatArray, screenWidth: Int, screenHeight: Int) + + @JvmStatic + private external fun nativeClearTouchData() } - // MVP private val presenter: GamePresenter = GamePresenter() - - // 管理器 private var fullscreenManager: GameFullscreenManager? = null private val virtualControlsManager = GameVirtualControlsManager() 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)) @@ -145,14 +138,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") - AppLog.i(TAG, "=== GameActivity Process Started ===") - AppLog.i(TAG, "Game process PID: ${android.os.Process.myPid()}") - AppLog.i(TAG, "GameActivity onCreate completed") + + 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) + } catch (e: Exception) {} } private fun applyThemeMode() { @@ -166,18 +209,11 @@ class GameActivity : SDLActivity(), GameContract.View { } private fun initializeErrorHandler() { - try { - ErrorHandler.init(this) - } catch (e: Exception) { - AppLog.e(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() { @@ -185,6 +221,7 @@ class GameActivity : SDLActivity(), GameContract.View { enableFullscreen() configureIME() } + hideNavigationBarDefinitively() } private fun initializeVirtualControls() { @@ -192,28 +229,19 @@ class GameActivity : SDLActivity(), GameContract.View { activity = this, sdlLayout = mLayout as ViewGroup, sdlSurface = mSurface, - onExitGame = { exitGame() } + disableSDLTextInput = { disableSDLTextInput() }, + 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) @@ -224,72 +252,58 @@ class GameActivity : SDLActivity(), GameContract.View { @SuppressLint("MissingSuperCall") @Deprecated("Deprecated in Java") - override fun onBackPressed() { - // 按下返回键不做任何操作,由悬浮菜单控制 - // 用户可以通过悬浮菜单退出游戏 - } + override fun onBackPressed() {} override fun onDestroy() { - AppLog.d(TAG, "GameActivity.onDestroy() called") - - virtualControlsManager.stop() - presenter.detach() - - super.onDestroy() - - // [重要] .NET runtime 不支持多次初始化 - // GameActivity 运行在独立进程,终止不影响主应用 - Handler(Looper.getMainLooper()).postDelayed({ - AppLog.d(TAG, "Terminating game process to ensure clean .NET runtime state") - Process.killProcess(Process.myPid()) - System.exit(0) - }, 100) + BlackBoxLogger.stopRecording() + + 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) } - // ==================== 公开方法 ==================== - - 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 dispatchTouchEvent(event: MotionEvent): Boolean { + val result = super.dispatchTouchEvent(event) + touchBridge.handleMotionEvent(event, resources) + + if (event.action == MotionEvent.ACTION_DOWN) { + hideNavigationBarDefinitively() + } + + return result } - override fun showError(title: String, message: String) { - showToast("$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, @@ -307,25 +321,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/init/InitializationActivity.kt b/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt new file mode 100644 index 00000000..49e801d5 --- /dev/null +++ b/app/src/main/java/com/app/ralaunch/feature/init/InitializationActivity.kt @@ -0,0 +1,275 @@ +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 +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +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.theme.RaLaunchTheme +import com.app.ralaunch.feature.main.MainActivityCompose +import com.app.ralaunch.core.common.util.AppLogger +import com.app.ralaunch.core.common.util.ArchiveExtractor +import com.app.ralaunch.core.common.ErrorHandler +import com.app.ralaunch.core.platform.runtime.RuntimeLibraryLoader +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +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) + + // ✅ 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) + + if (extracted) { + navigateToMain() + return + } + + permissionManager = PermissionManager(this).apply { initialize() } + + setContent { + RaLaunchTheme { + InitializationScreen( + permissionManager = permissionManager, + onComplete = { navigateToMain() }, + onExit = { finish() }, + onExtract = { components, onUpdate -> + startExtraction(components, onUpdate) + }, + prefs = prefs, + context = this + ) + } + } + } + + // ✅ FIX Android 7 crash (WindowInsets chỉ chạy API 26+) + private fun hideSystemUI() { + 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 + } + } + + private fun navigateToMain() { + startActivity(Intent(this, MainActivityCompose::class.java)) + finish() + } + + private fun startExtraction( + components: List, + onUpdate: (Int, Int, Boolean, String) -> Unit + ) { + if (isExtracting.getAndSet(true)) return + + executor.execute { + 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 + ) + } + } catch (e: Exception) { + AppLogger.error("InitActivity", "Extraction failed", e) + mainHandler.post { + ErrorHandler.handleError( + e.message + ?: getString(R.string.error_message_default), + e + ) + } + } finally { + isExtracting.set(false) + } + } + } + + private fun extractAll( + components: List, + onUpdate: (Int, Int, Boolean, String) -> Unit + ) { + val filesDir = filesDir + + components.forEachIndexed { index, component -> + + if (!component.needsExtraction) { + mainHandler.post { + 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) + ) + } + + val tempFile = + File(cacheDir, "temp_${component.fileName}") + + ArchiveExtractor.copyAssetToFile( + this, + component.fileName, + tempFile + ) + + mainHandler.post { + onUpdate( + index, + 30, + false, + getString(R.string.init_extracting) + ) + } + + val outputDir = + File(filesDir, component.name).apply { mkdirs() } + + 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 + ) + + component.fileName.endsWith(".tar.gz") -> + ArchiveExtractor.extractTarGz( + tempFile, + outputDir, + stripPrefix, + callback + ) + + else -> + ArchiveExtractor.extractTar( + tempFile, + outputDir, + stripPrefix, + callback + ) + } + + tempFile.delete() + + mainHandler.post { + onUpdate( + index, + 100, + true, + getString(R.string.init_complete) + ) + } + } + + extractRuntimeLibsIfNeeded() + } + + private fun extractRuntimeLibsIfNeeded() { + try { + val hasRuntimeLibs = + assets.list("")?.contains("runtime_libs.tar.xz") == true + + if (hasRuntimeLibs && + !RuntimeLibraryLoader.isExtracted(this) + ) { + runBlocking { + RuntimeLibraryLoader.extractRuntimeLibs( + this@InitializationActivity + ) { _, _ -> } + } + } + } catch (e: Exception) { + AppLogger.error( + "InitActivity", + "Failed to extract runtime libs: ${e.message}" + ) + } + } + + override fun onDestroy() { + super.onDestroy() + if (!executor.isShutdown) + executor.shutdownNow() + } +} diff --git a/app/src/main/java/com/app/ralaunch/feature/installer/GameExtractorUtils.kt b/app/src/main/java/com/app/ralaunch/feature/installer/GameExtractorUtils.kt index 6b28a3b1..d58a6f7c 100644 --- a/app/src/main/java/com/app/ralaunch/feature/installer/GameExtractorUtils.kt +++ b/app/src/main/java/com/app/ralaunch/feature/installer/GameExtractorUtils.kt @@ -9,22 +9,19 @@ import com.app.ralaunch.core.logging.AppLog 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 { private const val TAG = "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 ?: "", @@ -42,7 +39,7 @@ object GameExtractorUtils { } /** - * 解压 GOG .sh 文件 + * Extract GOG .sh file */ suspend fun extractGogSh( shFile: File, @@ -51,13 +48,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) @@ -65,7 +62,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?) { @@ -78,7 +75,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) @@ -94,11 +91,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, @@ -126,9 +123,9 @@ object GameExtractorUtils { } val extractor = BasicSevenZipExtractor( - zipFile.toPath(), - Paths.get(sourcePrefix), - outputDir.toPath(), + zipFile, + File(sourcePrefix), + outputDir, listener ) extractor.state = HashMap(state) @@ -151,9 +148,6 @@ object GameExtractorUtils { } } - /** - * GOG 游戏信息 - */ data class GogGameInfo( val id: String, val version: String, @@ -161,9 +155,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/feature/installer/plugins/CelesteInstallPlugin.kt b/app/src/main/java/com/app/ralaunch/feature/installer/plugins/CelesteInstallPlugin.kt index 7a225f38..b05e6c24 100644 --- a/app/src/main/java/com/app/ralaunch/feature/installer/plugins/CelesteInstallPlugin.kt +++ b/app/src/main/java/com/app/ralaunch/feature/installer/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/feature/patch/data/Patch.kt b/app/src/main/java/com/app/ralaunch/feature/patch/data/Patch.kt index b2fe37f2..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 com.app.ralaunch.core.logging.AppLog -import java.nio.file.Files -import java.nio.file.Path +import android.util.Log +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)) { - AppLog.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) { - AppLog.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 9154274b..407c4502 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,52 +7,42 @@ import com.app.ralaunch.core.extractor.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 { - val patchStorageBasePath = getPatchStorageBaseDirectory(customStoragePath) - patchStoragePath = patchStorageBasePath.resolve(PATCH_STORAGE_DIR).normalize() - if (!Files.isDirectory(patchStoragePath) || !Files.exists(patchStoragePath)) { - FileUtils.deleteFileWithinRoot(patchStoragePath, patchStorageBasePath) - 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) } @@ -60,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) } @@ -76,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) } @@ -122,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)) { - AppLog.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) { - AppLog.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)) { - AppLog.i(TAG, "补丁已存在, 将删除原补丁目录,重新安装, patch id: ${manifest.id}") - if (!FileUtils.deleteDirectoryRecursivelyWithinRoot(patchPath, patchStoragePath)) { - AppLog.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 { - AppLog.i(TAG, "正在安装新补丁, patch id: ${manifest.id}") + Log.i(TAG, "Installing new patch: ${manifest.id}") } - AppLog.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) { - AppLog.i(TAG, "配置文件不存在或加载失败,创建新配置") - PatchManagerConfig().also { it.saveToJson(configFilePath) } - } else { - AppLog.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)) { - AppLog.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)) { - FileUtils.deleteFileWithinRoot(dllPath, patchStoragePath) - AppLog.i(TAG, "已清理旧的共享 DLL: $dllName") + if (dllFile.exists()) { + dllFile.delete() + Log.i(TAG, "Cleaned legacy DLL: $dllName") } - } catch (e: IOException) { - AppLog.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 @@ -241,16 +201,17 @@ class PatchManager @JvmOverloads constructor( "Mono.Cecil.dll" ) - @Throws(IOException::class) - private fun getPatchStorageBaseDirectory(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).normalize() + + // CHANGED: Use absoluteFile instead of canonicalFile to prevent IOException crash on Android 7 + return File(baseDir, PATCH_STORAGE_DIR).absoluteFile } @JvmStatic @@ -260,46 +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() + ).extract() - // 获取已安装补丁的 ID -> 清单映射(用于版本比较) - val installedPatchMap = patchManager.installedPatches - .associateBy { it.manifest.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 -> - val manifest = PatchManifest.fromZip(patchZip) ?: return@forEach + extractedPatches.listFiles() + ?.filter { it.isFile && it.name.endsWith(".zip") } + ?.forEach { patchZip -> + try { + val manifest = PatchManifest.fromZip(patchZip) + ?: return@forEach val installedPatch = installedPatchMap[manifest.id] when { forceReinstall -> { - AppLog.i(TAG, "正在强制重新安装内置补丁: ${patchZip.fileName} (id: ${manifest.id})") + Log.i(TAG, "Force reinstalling: ${patchZip.name}") patchManager.installPatch(patchZip) } installedPatch == null -> { - AppLog.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) { - AppLog.i(TAG, "检测到补丁更新: ${manifest.id} (${installedVersion} -> ${bundledVersion}),正在自动更新...") + Log.i(TAG, "Updating patch: ${manifest.id} ($installedVersion -> $bundledVersion)") patchManager.installPatch(patchZip) } else { - AppLog.i(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 { - AppLog.d(TAG, "constructStartupHooksEnvVar: Input patches count = ${patches.size}") - patches.forEachIndexed { index, p -> - AppLog.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) { - AppLog.w(TAG, "constructStartupHooksEnvVar: Duplicate patch ID filtered: ${p.manifest.id}") - } - isNew - } - .map { it.getEntryAssemblyAbsolutePath().toString() } - .distinct() - .joinToString(":") - - AppLog.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 29b15270..57f4e35a 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 com.app.ralaunch.core.logging.AppLog 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 { - AppLog.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) { - AppLog.w(TAG, "Save configuration file failed: ${AppLog.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? { - AppLog.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)) { - AppLog.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) { - AppLog.w(TAG, "加载配置文件失败: ${AppLog.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 c45194ff..552c1f89 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? { AppLog.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? { - AppLog.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)) { - AppLog.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/com/app/ralaunch/feature/patch/ui/PatchManagementSubScreen.kt b/app/src/main/java/com/app/ralaunch/feature/patch/ui/PatchManagementSubScreen.kt index 97fcbb58..e1e409ce 100644 --- a/app/src/main/java/com/app/ralaunch/feature/patch/ui/PatchManagementSubScreen.kt +++ b/app/src/main/java/com/app/ralaunch/feature/patch/ui/PatchManagementSubScreen.kt @@ -37,11 +37,8 @@ import com.app.ralaunch.feature.patch.vm.PatchManagementViewModel import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import java.io.File +import java.io.FileOutputStream -/** - * 补丁管理子页面 - * 横屏双栏布局:左侧游戏列表,右侧补丁列表 - */ @Composable @OptIn(ExperimentalMaterial3Api::class) fun PatchManagementSubScreen( @@ -49,44 +46,97 @@ fun PatchManagementSubScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val viewModel: PatchManagementViewModel = koinViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // 文件选择器 + val gameRepository: GameRepositoryV2? = remember { + try { KoinJavaComponent.getOrNull(GameRepositoryV2::class.java) } catch (_: Exception) { null } + } + val patchManager: PatchManager? = remember { + try { KoinJavaComponent.getOrNull(PatchManager::class.java) } catch (_: Exception) { null } + } + + var games by remember { mutableStateOf>(emptyList()) } + var selectedGame by remember { mutableStateOf(null) } + 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? -> uri?.let { scope.launch { - val success = viewModel.importPatch(it) - if (success) { - Toast.makeText(context, R.string.patch_dialog_import_successful, Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(context, R.string.patch_dialog_import_failed, Toast.LENGTH_SHORT).show() - } - if (success) { - viewModel.refreshPatches() + importPatchFile(context, patchManager, it) { success -> + if (success) { + selectedGame?.let { game -> + patches = patchManager?.getApplicablePatches(game.gameId) ?: emptyList() + } + } } } } } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.patch_dialog_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - } - }, - actions = { - TextButton( - onClick = { - patchFilePicker.launch("application/zip") + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.95f), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.patch_dialog_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Row { + TextButton( + onClick = { + patchFilePicker.launch("application/zip") + } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.patch_dialog_import)) + } + + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) } ) { Icon( @@ -98,55 +148,30 @@ fun PatchManagementSubScreen( Text(stringResource(R.string.patch_dialog_import)) } } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 20.dp, vertical = 16.dp) - ) { - // 主内容区域 - 横向双栏 - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - // 左侧:游戏列表 - GameListPanel( - games = uiState.games, - selectedIndex = uiState.selectedGameIndex, - onGameSelected = viewModel::selectGame, - modifier = Modifier.weight(1f) - ) - - // 右侧:补丁列表 - PatchListPanel( - patches = uiState.patches, - selectedGame = uiState.selectedGame, - isPatchEnabled = viewModel::isPatchEnabled, - onPatchEnabledChange = { patch, enabled -> - viewModel.setPatchEnabled(patch.manifest.id, enabled) - val statusText = if (enabled) { - context.getString(R.string.patch_enabled) - } else { - context.getString(R.string.patch_disabled) - } - uiState.selectedGame?.let { selectedGame -> - Toast.makeText( - context, - context.getString( - R.string.patch_status_changed_message, - selectedGame.displayedName, - patch.manifest.name, - statusText - ), - Toast.LENGTH_SHORT - ).show() - } - }, - modifier = Modifier.weight(1f) - ) + + Row( + modifier = Modifier + .fillMaxSize() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + GameListPanel( + games = games, + selectedIndex = selectedGameIndex, + onGameSelected = { game, index -> + selectedGame = game + selectedGameIndex = index + }, + modifier = Modifier.weight(1f) + ) + + PatchListPanel( + patches = patches, + selectedGame = selectedGame, + patchManager = patchManager, + modifier = Modifier.weight(1f) + ) + } } } } @@ -196,7 +221,7 @@ private fun GameListPanel( itemsIndexed(games) { index, game -> GameSelectableItem( game = game, - isSelected = index == selectedIndex, + isSelected = index == selectedIndex, // FIXED onClick = { onGameSelected(game, index) } ) } @@ -238,7 +263,6 @@ private fun GameSelectableItem( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // 游戏图标 Box( modifier = Modifier .size(40.dp) @@ -246,7 +270,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() @@ -364,6 +388,13 @@ private fun PatchItem( isEnabled: Boolean, onCheckedChange: (Boolean) -> Unit ) { + val gameAsmPath = remember(selectedGame) { + selectedGame.gameExePathFull?.let { File(it) } ?: File(selectedGame.gameExePathRelative) + } + var isEnabled by remember(patch, selectedGame) { + mutableStateOf(patchManager?.isPatchEnabled(gameAsmPath, patch.manifest.id) ?: false) + } + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), @@ -402,3 +433,37 @@ private fun PatchItem( } } } + +private suspend fun importPatchFile( + context: android.content.Context, + patchManager: PatchManager?, + uri: Uri, + onResult: (Boolean) -> Unit +) { + withContext(Dispatchers.IO) { + try { + TemporaryFileAcquirer().use { tfa -> + val tempPatchFile = tfa.acquireTempFilePath("imported_patch.zip") + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempPatchFile).use { outputStream -> + StreamUtils.transferTo(inputStream, outputStream) + } + } + val result = patchManager?.installPatch(tempPatchFile) ?: false + withContext(Dispatchers.Main) { + if (result) { + Toast.makeText(context, R.string.patch_dialog_import_successful, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, R.string.patch_dialog_import_failed, Toast.LENGTH_SHORT).show() + } + onResult(result) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.patch_dialog_import_failed, Toast.LENGTH_SHORT).show() + onResult(false) + } + } + } +} 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46b89e5a..71683626 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,12 @@ annotationJvm = "1.9.1" activity = "1.10.1" kotlin = "2.3.21" coreKtx = "1.17.0" -kotlinx-serialization-json = "1.11.0" -kotlinx-coroutines = "1.10.2" +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 = "5.0.7" konfetti = "2.0.5" @@ -57,6 +61,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 new file mode 100644 index 00000000..9562dd04 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + alias(libs.plugins.kmp) + alias(libs.plugins.android.library) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.plugin.serialization) +} + +compose.resources { + packageOfResClass = "com.app.ralaunch.shared.generated.resources" +} + +kotlin { + 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(17) + + sourceSets { + val commonMain by getting { + kotlin.srcDir("src/commonMain/kotlin") + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.materialIconsExtended) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + + implementation(libs.haze) + implementation(libs.haze.materials) + + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + + implementation(libs.datastore.preferences.core) + } + } + + val androidMain by getting { + kotlin.srcDir("src/androidMain/kotlin") + dependencies { + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.android) + implementation(libs.datastore.preferences) + implementation(libs.lifecycle.viewmodel) + implementation(libs.lifecycle.viewmodel.compose) + } + } + } +} + +android { + namespace = "com.app.ralaunch.shared" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5") +} 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 new file mode 100644 index 00000000..de393994 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonControlLayoutStorage.kt @@ -0,0 +1,99 @@ +package com.app.ralaunch.shared.core.data.local + +import com.app.ralaunch.shared.core.platform.AppConstants +import com.app.ralaunch.shared.core.data.repository.ControlLayoutStorage +import com.app.ralaunch.shared.core.model.domain.ControlLayout +import com.app.ralaunch.shared.core.model.domain.ControlPackManifest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File // Thay kotlin.io.path.* + +class CommonControlLayoutStorage( + private val pathsProvider: StoragePathsProvider +) : ControlLayoutStorage { + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + } + + override suspend fun loadAllLayouts(): List = withContext(Dispatchers.Default) { + 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(file.readText()) + }.getOrNull() + } ?: emptyList() + } + + override suspend fun saveLayout(layout: ControlLayout) = withContext(Dispatchers.Default) { + layoutsDirFull.mkdirs() + layoutFileFull(layout.id).writeText(json.encodeToString(layout)) + } + + override suspend fun deleteLayout(id: String) = withContext(Dispatchers.Default) { + val file = layoutFileFull(id) + if (file.exists()) file.delete() + Unit + } + + override suspend fun loadCurrentLayoutId(): String? = withContext(Dispatchers.Default) { + if (!layoutStateFull.exists()) return@withContext null + runCatching { + json.decodeFromString(layoutStateFull.readText()).currentLayoutId + }.getOrNull() + } + + override suspend fun saveCurrentLayoutId(id: String) = withContext(Dispatchers.Default) { + layoutsDirFull.mkdirs() + val state = ControlLayoutState(currentLayoutId = id) + layoutStateFull.writeText(json.encodeToString(state)) + } + + override suspend fun importPack(packPath: String): Result = withContext(Dispatchers.Default) { + runCatching { + val packFile = File(packPath) + if (!packFile.exists()) { + throw IllegalArgumentException("File not found: $packPath") + } + val layout = json.decodeFromString(packFile.readText()) + saveLayout(layout) + layout + } + } + + override suspend fun exportLayout(layout: ControlLayout, outputPath: String): Result = + withContext(Dispatchers.Default) { + runCatching { + val outputFile = File(outputPath) + outputFile.parentFile?.mkdirs() + outputFile.writeText(json.encodeToString(layout)) + outputPath + } + } + + override suspend fun fetchRemotePacks(): Result> { + return Result.success(emptyList()) + } + + private val layoutsDirFull + get() = File(pathsProvider.controlLayoutsDirPathFull()) + + private fun layoutFileFull(layoutId: String) = + File(layoutsDirFull, "$layoutId.json") + + private val layoutStateFull + get() = File(layoutsDirFull, AppConstants.Files.CONTROL_LAYOUT_STATE) +} + +@Serializable +private data class ControlLayoutState( + val currentLayoutId: String? = null +) 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 new file mode 100644 index 00000000..517166b6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/app/ralaunch/shared/core/data/local/CommonGameListStorage.kt @@ -0,0 +1,115 @@ +package com.app.ralaunch.shared.core.data.local + +import com.app.ralaunch.shared.core.platform.AppConstants +import com.app.ralaunch.shared.core.data.repository.GameListStorage +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 java.io.File // Thay kotlin.io.path.* +import kotlin.random.Random + +class CommonGameListStorage( + private val pathsProvider: StoragePathsProvider +) : GameListStorage { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun loadGameList(): List { + return try { + if (!gameListFile.exists()) return emptyList() + + val gameList = json.decodeFromString(gameListFile.readText()) + gameList.games.mapNotNull { storageRootPathRelative -> + loadGameInfo(storageRootPathRelative) + } + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + override fun saveGameList(games: List) { + try { + gamesDirFile.mkdirs() + + val gameList = GameList(games = games.map { it.id }) + gameListFile.writeText(json.encodeToString(gameList)) + + games.forEach { saveGameInfo(it) } + cleanupDeletedGameStorageRoots(games.map { it.id }) + } catch (e: Exception) { + e.printStackTrace() + } + } + + 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 storageRootFile = File(gamesDirFile, storageRootPathRelative) + storageRootFile.mkdirs() + return Pair(storageRootFile.absolutePath, storageRootPathRelative) + } + + private fun loadGameInfo(storageRootPathRelative: String): GameItem? { + return try { + val gameInfoFile = File( + File(gamesDirFile, storageRootPathRelative), + AppConstants.Files.GAME_INFO + ) + if (!gameInfoFile.exists()) return null + + json.decodeFromString(gameInfoFile.readText()).also { + it.gameListStorageParent = this + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private fun saveGameInfo(game: GameItem) { + try { + val storageRootFile = File(gamesDirFile, game.id) + val gameInfoFile = File(storageRootFile, AppConstants.Files.GAME_INFO) + + storageRootFile.mkdirs() + gameInfoFile.writeText(json.encodeToString(game)) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun cleanupDeletedGameStorageRoots(keepRoots: List) { + if (!gamesDirFile.exists()) return + + val keepSet = keepRoots.toSet() + gamesDirFile.listFiles() + ?.filter { it.isDirectory } + ?.filter { it.name !in keepSet } + ?.forEach { dir -> + runCatching { dir.deleteRecursively() } + } + } + + private fun randomHex(length: Int): String { + val chars = "0123456789abcdef" + return buildString(length) { + repeat(length) { + append(chars[Random.nextInt(chars.length)]) + } + } + } + + private val gamesDirFile + get() = File(pathsProvider.gamesDirPathFull()) + + private val gameListFile + get() = File(gamesDirFile, AppConstants.Files.GAME_LIST) +}