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