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