feat: v1.1.0 - All-in-One Standalone app with embedded Python 3.14 runtime, AFN direct updater, and server tools
This commit is contained in:
1 parent
249c922a3e
commit
e7e639b5a2
42 files changed
+7306
-1509
No files matched your search
@@ -1,95 +1,111 @@
|
||||
# Escape penacony standalone 📱✨
|
||||
## ppSR Scene & Map Teleporter - Native Android Application
|
||||
# Escape Penacony Standalone 📱✨
|
||||
## ppSR All-in-One Server & Map Teleporter - Native Android Application
|
||||
|
||||
แอปพลิเคชัน Android Standalone สำหรับจัดการและเทเลพอร์ตแผนที่ในเซิร์ฟเวอร์จำลอง **ppSR (Project Star Rail)** บนระบบปฏิบัติการ Android โดยตรง พร้อมระบบสัมผัส (Touch UI) ที่ทันสมัย และระบบอัปเดตอัตโนมัติในตัว (In-App Auto-Updater)
|
||||
**Escape Penacony Standalone** เป็นแอปพลิเคชัน Android แบบ All-in-One ที่รวมทั้ง **ตัวรันเซิร์ฟเวอร์จำลอง ppSR (Embedded Python 3.14)**, **ตัวอัปเดตเซิร์ฟเวอร์ตรงจากต้นทาง AFN**, **ระบบวาร์ปแผนที่ 85 ฉาก**, และ **เครื่องมือปรับแต่งเซิร์ฟเวอร์** ไว้ในแอปเดียวจบ **โดยไม่ต้องใช้สิทธิ์ Root, ไม่ต้องใช้ Shizuku และไม่ต้องติดตั้งแอป com.afn.ppSR แยกต่างหาก**
|
||||
|
||||
- **ชื่อแอปพลิเคชันเต็ม (Full Name)**: `Escape penacony standalone`
|
||||
- **ชื่อแอปพลิเคชันที่แสดงบนอุปกรณ์ (Display Name)**: `Escape penacony`
|
||||
- **Application ID / Package**: `com.catlas.escapepenacony`
|
||||
- **สถาปัตยกรรม**: Native Android (Kotlin + Jetpack Compose + Material Design 3)
|
||||
- **เวอร์ชันล่าสุด (Current Version)**: `v1.1.0`
|
||||
- **สถาปัตยกรรม (Architecture)**: Native Android (Kotlin + Jetpack Compose + Material Design 3 + Embedded Chaquopy Python 3.14)
|
||||
- **Target ABI**: `arm64-v8a` (Android 8.0+ / API 26+)
|
||||
|
||||
---
|
||||
|
||||
## 🌟 ฟีเจอร์หลัก (Key Features)
|
||||
|
||||
1. **Native Touch UI (Jetpack Compose & Material Design 3)**
|
||||
- อินเทอร์เฟซมืด (Dark Cyberpunk Theme) ดีไซน์พิเศษ
|
||||
- รองรับแถบเลือกดวงดาว (Planet Tabs) แบบเลื่อนแนวนอน และระบบค้นหาฉาก/พิกัดแบบเรียลไทม์
|
||||
2. **ฐานข้อมูลแผนที่ครบถ้วน (Full Data Parity)**
|
||||
- ครอบคลุม **8 ดวงดาว 85 ฉาก/แผนที่** จาก `scenes.json`
|
||||
- แยกระบบหมวดหมู่ด้วยแท็กสีชัดเจน:
|
||||
- ⚔️ **Combat Ready**: ฉากที่รองรับเสาต่อสู้ Calyx ดั้งเดิมในไคลเอนต์ Official
|
||||
- 🗺️ **Safe Scenery**: ฉากสำหรับเดินชมวิว เสา Calyx ซ่อนใต้ดินที่ระดับความลึก Y=-999999 ปลอดภัยจากการแครช
|
||||
- 🧪 **4.7 Beta Exclusive**: ฉากพิเศษสำหรับไคลเอนต์เบต้า 4.7
|
||||
3. **ระบบจัดการไฟล์ผ่าน Android Storage Access Framework (SAF)**
|
||||
- เลือกระบุไฟล์ `persistent.json` ผ่าน Document Picker รองรับ Android 10, 11, 12, 13, 14, และ 15+
|
||||
- กดปุ่ม **Teleport** เพียงครั้งเดียวเพื่ออัปเดตพิกัดลงไฟล์ `persistent.json` ทันที พร้อมระบบสำรองไฟล์อัตโนมัติ
|
||||
- มีปุ่ม **Copy JSON** สำหรับคัดลอก JSON พิกัดไปใช้งานต่อได้ทันที
|
||||
4. **Custom Coordinates Input**
|
||||
- ปุ่มลอย (+) สำหรับกรอก Plane ID, X, Y, Z, และ Map Layer ที่กำหนดเองได้อิสระ
|
||||
5. **In-App Auto-Updater & OTA Hot Sync**
|
||||
- **ตรวจจับเวอร์ชันใหม่ผ่าน GitHub Releases API**: เปรียบเทียบ `tag_name` กับเวอร์ชันในเครื่อง ดาวน์โหลด `.apk` และเรียกตัวติดตั้งระบบอัตโนมัติผ่าน `FileProvider`
|
||||
- **OTA Map Update**: ปุ่มซิงค์ดาวน์โหลด `scenes.json` ตัวล่าสุดจาก GitHub Raw ได้ทันทีโดยไม่ต้องลง APK ใหม่
|
||||
6. **รองรับ 3 ภาษา (Trilingual Support)**
|
||||
- สลับภาษาระหว่าง **English (EN)**, **ภาษาไทย (TH)**, และ **简体中文 (ZH)** ได้ทันทีผ่านปุ่มบนแถบเมนูด้านบน
|
||||
### 1. 🚀 Embedded Python 3.14 Server Runtime (รันเซิร์ฟเวอร์ในตัวเอง 100%)
|
||||
- **ไม่ต้องลงแอป ppSR เพิ่ม:** ฝัง Chaquopy Python 3.14 Native Runtime ในตัวแอป พร้อมไลบรารี CPython `libpython3.14.so` และ `libchaquopy_java.so`
|
||||
- **Foreground Service:** รันเป็นบริการเบื้องหลังพร้อมแจ้งเตือนสถานะ (`specialUse` Service) ไม่ถูก Android ฆ่าโปรเซสเมื่อสลับไปเปิดแอปเกม
|
||||
- **Network Ports:**
|
||||
- Dispatch Server (HTTP / Flask): `127.0.0.1:21000`
|
||||
- Gameserver Forward (KCP Packet): `127.0.0.1:23301`
|
||||
- **Live Terminal Console:** หน้าจอแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบเรียลไทม์ พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear)
|
||||
|
||||
### 2. 🛠️ เครื่องมือปรับแต่งและปุ่มต่างๆ ของเซิร์ฟเวอร์ (Server Tools)
|
||||
- **🔑 Set Password / Key:** มีหน้าต่างกำหนดรหัสผ่านเซิร์ฟเวอร์ (`key`) ใน `persistent.json`
|
||||
- **👤 Edit Trailblazer:** ปรับแต่งเพศตัวเอก (หญิง Stelle / ชาย Caelus) และ Path (ทำลายล้าง, อนุรักษ์, ประสาน, ลบล้าง, ปิติสุข) รวมถึง March 7th ได้ทันที
|
||||
- **⚡ Send Hotfix (Online / Offline Toggle):** สลับโหมด `send_hotfix` ในคลิกเดียว:
|
||||
- *เปิด (True):* สำหรับต่อเน็ตดึง Assets จาก Hoyoverse
|
||||
- *ปิด (False):* สำหรับเล่นแบบออฟไลน์ 100% (ช่วยแก้ปัญหาเปิดเกมแล้วจอดำ)
|
||||
- **📁 Misc Files Explorer & Editor:** ตัวจัดการไฟล์ในโฟลเดอร์ `misc/` ในตัว สามารถเปิดดูและแก้ไขไฟล์คอนฟิกต่างๆ เช่น `config.json`, `hotfix.json`, `freesr-data.json`, `persistent.json` ได้สดๆ ภายในแอป
|
||||
|
||||
### 3. 🔄 AFN Direct Updater (อัปเดตไฟล์เซิร์ฟเวอร์ตรงจากต้นทาง)
|
||||
- **เชื่อมต่อตรงกับ File Garden:** ตรวจสอบเวอร์ชันล่าสุดผ่าน `https://file.garden/aewBviVxx2XDWgM-/VERSIONS`
|
||||
- **ระบบติดตั้งแบบ Staging & Rollback:** ดาวน์โหลด `build.zip` ลงพื้นที่ปลอดภัย ป้องกันช่องโหว่ Zip Slip Path Traversal
|
||||
- **รักษาข้อมูลผู้ใช้เสมอ (Data Preservation):** ระบบจะสำรองและดึงไฟล์ `persistent.json` และ `config.json` ของผู้ใช้กลับมาใส่ในเวอร์ชันใหม่ให้อัตโนมัติ เซฟไม่หายแน่นอน
|
||||
- **Select Version Archive:** สามารถเลือกติดตั้งเวอร์ชันเก่าหรือเวอร์ชันใหม่ย้อนหลังได้อิสระ
|
||||
|
||||
### 4. 🗺️ ตัวเลือกโลกและวาร์ปแผนที่ (Escape Penacony Teleporter)
|
||||
- ครอบคลุม **8 ดวงดาว 85 ฉาก/แผนที่** จาก `scenes.json` (Astral Express, Space Station, Jarilo-VI, Xianzhou Luofu, Penacony ฯลฯ)
|
||||
- **ระบบค้นหาและฟิลเตอร์:** ค้นหาชื่อฉากหรือ Plane ID และแยกแท็กชัดเจน:
|
||||
- ⚔️ **Combat Ready**: มีเสา Calyx สำหรับต่อสู้
|
||||
- 🗺️ **Safe Explore**: เสา Calyx ซ่อนที่ความลึก Y=-999999 ปลอดภัยจากการแครช
|
||||
- 🧪 **Beta Exclusive**: ฉากพิเศษสำหรับไคลเอนต์เบต้า
|
||||
- **Custom Coordinates:** กรอก Plane ID, X, Y, Z และ Map Layer ที่กำหนดเองได้อิสระ
|
||||
- **Smart Teleport Flow:** หากเซิร์ฟเวอร์กำลังทำงาน ระบบจะหยุดชั่วคราว $\rightarrow$ เขียนไฟล์ `persistent.json` (UTF-8 No BOM) $\rightarrow$ รีสตาร์ตเซิร์ฟเวอร์ให้ใหม่อัตโนมัติ
|
||||
- **รองรับ 3 ภาษา:** สลับระหว่าง **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)** ได้ทันที
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ โครงสร้างโปรเจกต์ (Project Structure)
|
||||
|
||||
```text
|
||||
Android APK/
|
||||
Escape-penacony-standalone/
|
||||
├── app/
|
||||
│ ├── build.gradle.kts
|
||||
│ ├── proguard-rules.pro
|
||||
│ └── src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── assets/
|
||||
│ │ └── scenes.json # ฐานข้อมูลแผนที่ 8 โลก 85 แมพ
|
||||
│ ├── java/com/catlas/escapepenacony/
|
||||
│ │ ├── MainActivity.kt # Activity หลักและ SAF Document Picker
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── SceneModels.kt # โมเดลข้อมูล Planet & Scene & Language
|
||||
│ │ │ └── PersistentConfig.kt # โครงสร้าง JSON ของ persistent.json
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── SceneRepository.kt # โหลดและบันทึก scenes.json (รองรับ OTA)
|
||||
│ │ │ ├── StorageManager.kt # อ่าน/เขียน persistent.json ผ่าน SAF
|
||||
│ │ │ └── UpdateManager.kt # GitHub Releases API & APK Self-Updater
|
||||
│ │ └── ui/
|
||||
│ │ ├── MainScreen.kt # หน้าจอหลัก Compose
|
||||
│ │ ├── Components.kt # SceneCard, Badges, Dialogs
|
||||
│ │ └── theme/ # Color, Theme, Type
|
||||
│ └── res/
|
||||
│ ├── values/ # strings.xml, colors.xml, themes.xml
|
||||
│ └── xml/file_paths.xml # FileProvider path สำหรับติดตั้ง APK
|
||||
│ ├── libs/
|
||||
│ │ └── chaquopy_java.jar # Chaquopy Java API Runtime
|
||||
│ └── src/
|
||||
│ ├── main/
|
||||
│ │ ├── AndroidManifest.xml # Foreground Service & Permissions
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── scenes.json # ฐานข้อมูลแผนที่ 8 โลก 85 แมพ
|
||||
│ │ │ └── chaquopy/ # Chaquopy stdlib, bootstrap & app.imy
|
||||
│ │ ├── jniLibs/arm64-v8a/ # Native libraries (libpython3.14.so ฯลฯ)
|
||||
│ │ ├── python/ # Python scripts (run_ppsr.py, loader.py)
|
||||
│ │ └── java/com/catlas/escapepenacony/
|
||||
│ │ ├── MainActivity.kt # Main Activity & Permission requests
|
||||
│ │ ├── service/
|
||||
│ │ │ └── ServerService.kt # Foreground Service จัดการ Python lifecycle
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── PersistentRepository.kt # จัดการ persistent.json & misc files
|
||||
│ │ │ ├── UpdateRepository.kt # ดาวน์โหลดและติดตั้ง build.zip จาก AFN
|
||||
│ │ │ └── SceneRepository.kt # โหลดและแปลงพิกัดฉากจาก scenes.json
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── SceneModels.kt # โมเดลข้อมูล Planet & Scene
|
||||
│ │ │ └── PersistentConfig.kt # โครงสร้าง Config พิกัด
|
||||
│ │ └── ui/
|
||||
│ │ ├── MainScreen.kt # Compose UI หน้าจอหลัก 3 แท็บ
|
||||
│ │ ├── Components.kt # Reusable UI widgets
|
||||
│ │ └── theme/ # Cyberpunk Dark Theme
|
||||
│ └── test/java/com/catlas/escapepenacony/
|
||||
│ └── UpdateAndSceneTest.kt # Automated Unit Tests
|
||||
├── gradle/
|
||||
│ ├── libs.versions.toml # Version Catalog
|
||||
│ └── wrapper/ # Gradle Wrapper 8.9
|
||||
│ └── libs.versions.toml
|
||||
├── build.gradle.kts
|
||||
├── settings.gradle.kts
|
||||
└── gradlew / gradlew.bat
|
||||
└── settings.gradle.kts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ วิธีการเปิดและคอมไพล์โปรเจกต์ (How to Build)
|
||||
|
||||
### 1. เปิดผ่าน Android Studio (แนะนำ)
|
||||
1. เปิดโปรแกรม **Android Studio**
|
||||
2. เลือก **Open** แล้วชี้ไปที่โฟลเดอร์ `Android APK`
|
||||
3. รอ Android Studio ซิงค์ Gradle ให้เสร็จสิ้น
|
||||
4. เลือกเมนู **Build** > **Build Bundle(s) / APK(s)** > **Build APK(s)** หรือกด Run ลงบนโทรศัพท์ Android / Emulator
|
||||
|
||||
### 2. คอมไพล์ผ่าน Command Line (CLI)
|
||||
เปิด Terminal หรือ PowerShell ที่โฟลเดอร์ `Android APK`:
|
||||
## 🛠️ วิธีการบิลด์ (How to Build)
|
||||
|
||||
### คอมไพล์ผ่าน Command Line:
|
||||
```bash
|
||||
# คอมไพล์ Debug APK
|
||||
# ทดสอบ Unit Tests ทั้งหมด
|
||||
./gradlew test
|
||||
|
||||
# สร้างไฟล์ Debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# คอมไพล์ Release APK
|
||||
# สร้างไฟล์ Release APK
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
ไฟล์ APK ที่สร้างเสร็จจะอยู่ที่:
|
||||
`app/build/outputs/apk/debug/app-debug.apk`
|
||||
ไฟล์ APK ที่บิลด์เสร็จแล้วจะอยู่ที่:
|
||||
- `app/build/outputs/apk/release/app-release.apk`
|
||||
- หรือตัวสำเนาที่ root โฟลเดอร์: `Escape-penacony-standalone-v1.1.0.apk`
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
This tool is for educational, research, and offline testing purposes only. All game data rights belong to their respective creators and owners.
|
||||
+18
-4
@@ -12,10 +12,21 @@ android {
|
||||
applicationId = "com.catlas.escapepenacony"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "v1.0.1"
|
||||
versionCode = 3
|
||||
versionName = "v1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
assets.srcDirs("src/main/assets")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -42,8 +53,11 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files("libs/chaquopy_java.jar"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
@@ -52,8 +66,8 @@ dependencies {
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation("dev.rikka.shizuku:api:13.1.5")
|
||||
implementation("dev.rikka.shizuku:provider:13.1.5")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
Binary file not shown.
@@ -2,9 +2,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name="com.chaquo.python.android.PyApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -23,6 +27,16 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.ServerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Local Python Game Server Execution" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -33,14 +47,6 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"assets": {
|
||||
"bootstrap-native/arm64-v8a/_struct.cpython-314-aarch64-linux-android.so": "9b083e7b97e92291f73d80d7495c9cee8351596b",
|
||||
"requirements-arm64-v8a.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33",
|
||||
"bootstrap-native/arm64-v8a/java/chaquopy.so": "1284a42a1b74c8fc5c8705a4f6f309134ecef4a8",
|
||||
"bootstrap.imy": "6c6d7c85e4764e96924001f9d6290501bfbfc704",
|
||||
"bootstrap-native/arm64-v8a/math.cpython-314-aarch64-linux-android.so": "dc859c93d3fc01f2d087471052895f230f732436",
|
||||
"cacert.pem": "fec7b8c0a17c1c555fd33baee53678f31b7645ee",
|
||||
"bootstrap-native/arm64-v8a/_bz2.cpython-314-aarch64-linux-android.so": "b48b9abf1f87d587a5f5beb4b38bb17acf29c4d3",
|
||||
"bootstrap-native/arm64-v8a/_ctypes.cpython-314-aarch64-linux-android.so": "50a6a36dab6a9ebe4fca5797872e8d2c3538db56",
|
||||
"bootstrap-native/arm64-v8a/mmap.cpython-314-aarch64-linux-android.so": "b571492fdceac1279bedc8163e27ac205e104b14",
|
||||
"app.imy": "40315c3e71d331e1c300e25e24d7084182ac150b",
|
||||
"stdlib-common.imy": "8715635411fe145000fbf3b7ce4c3a085646d300",
|
||||
"bootstrap-native/arm64-v8a/_random.cpython-314-aarch64-linux-android.so": "1c6c12f9f43546da0e108031f824b7fd6e79a36a",
|
||||
"bootstrap-native/arm64-v8a/_lzma.cpython-314-aarch64-linux-android.so": "6f3f86f607b4d775452c128858fa26d19c7da56a",
|
||||
"bootstrap-native/arm64-v8a/zlib.cpython-314-aarch64-linux-android.so": "2d164f648a0f97c1d8710d41cb66754e9daff44e",
|
||||
"bootstrap-native/arm64-v8a/binascii.cpython-314-aarch64-linux-android.so": "ff4d2c47c081af6518c8a6a6fa07edcc077b1b6b",
|
||||
"stdlib-arm64-v8a.imy": "ebebf5dc919d7e686936904604fe9a29fede81e9",
|
||||
"requirements-common.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33"
|
||||
},
|
||||
"extract_packages": [],
|
||||
"python_version": "3.14"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,82 +1,40 @@
|
||||
package com.catlas.escapepenacony
|
||||
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.*
|
||||
import com.catlas.escapepenacony.data.ShizukuHelper
|
||||
import com.catlas.escapepenacony.data.StorageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.catlas.escapepenacony.ui.MainScreen
|
||||
import com.catlas.escapepenacony.ui.theme.EscapePenaconyTheme
|
||||
import rikka.shizuku.Shizuku
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var currentPersistentUri by mutableStateOf<Uri?>(null)
|
||||
private var shizukuPermissionGrantedEvent by mutableStateOf(0L)
|
||||
|
||||
private val binderReceivedListener = Shizuku.OnBinderReceivedListener {
|
||||
if (ShizukuHelper.hasPermission()) {
|
||||
shizukuPermissionGrantedEvent = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
private val shizukuPermissionListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
|
||||
if (requestCode == ShizukuHelper.REQUEST_CODE && grantResult == PackageManager.PERMISSION_GRANTED) {
|
||||
shizukuPermissionGrantedEvent = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
// Storage Access Framework Document Picker for persistent.json
|
||||
private val openDocumentLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
StorageManager.savePersistentUri(this, uri)
|
||||
currentPersistentUri = uri
|
||||
}
|
||||
}
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ -> }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
try {
|
||||
Shizuku.addBinderReceivedListenerSticky(binderReceivedListener)
|
||||
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
|
||||
} catch (_: Throwable) {}
|
||||
|
||||
// Restore previously selected persistent.json if available
|
||||
currentPersistentUri = StorageManager.getPersistentUri(this)
|
||||
// Request notification permission for foreground service on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
EscapePenaconyTheme {
|
||||
MainScreen(
|
||||
currentPersistentUri = currentPersistentUri,
|
||||
shizukuPermissionGrantedEvent = shizukuPermissionGrantedEvent,
|
||||
onPickPersistentFile = {
|
||||
openDocumentLauncher.launch(arrayOf("application/json", "*/*"))
|
||||
}
|
||||
)
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
try {
|
||||
Shizuku.removeBinderReceivedListener(binderReceivedListener)
|
||||
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.catlas.escapepenacony.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
data class TrailblazerConfig(
|
||||
val gender: String, // "Male" or "Female"
|
||||
val tbPath: String, // "Destruction", "Preservation", "Harmony", "Remembrance", "Elation"
|
||||
val marchPath: String // "Preservation", "Hunt"
|
||||
)
|
||||
|
||||
data class SceneTeleportPayload(
|
||||
val planeId: Int,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val z: Int,
|
||||
val calyxX: Int,
|
||||
val calyxY: Int,
|
||||
val calyxZ: Int,
|
||||
val mapLayer: Int,
|
||||
val calyxGroupId: Int = 186,
|
||||
val calyxInstId: Int = 300001,
|
||||
val calyxEntityId: Int = 1337,
|
||||
val calyxPropId: Int = 808
|
||||
)
|
||||
|
||||
class PersistentRepository(private val context: Context) {
|
||||
|
||||
val serverBuildDir: File
|
||||
get() = File(context.filesDir, "server/build/build")
|
||||
|
||||
val miscDir: File
|
||||
get() = File(serverBuildDir, "misc")
|
||||
|
||||
val persistentFile: File
|
||||
get() = File(miscDir, "persistent.json")
|
||||
|
||||
fun isServerInstalled(): Boolean {
|
||||
return File(serverBuildDir, "ppsr.pyc").exists()
|
||||
}
|
||||
|
||||
suspend fun readPersistentJson(): Result<JSONObject> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!persistentFile.exists()) {
|
||||
return@withContext Result.failure(FileNotFoundException("persistent.json not found"))
|
||||
}
|
||||
val text = persistentFile.readText(StandardCharsets.UTF_8)
|
||||
Result.success(JSONObject(text))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writePersistentJson(json: JSONObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
miscDir.mkdirs()
|
||||
val backupFile = File(miscDir, "persistent.json.bak")
|
||||
if (persistentFile.exists()) {
|
||||
persistentFile.copyTo(backupFile, overwrite = true)
|
||||
}
|
||||
|
||||
val tempFile = File(miscDir, "persistent.json.tmp_${System.currentTimeMillis()}")
|
||||
val formatted = json.toString(4)
|
||||
tempFile.writeText(formatted, StandardCharsets.UTF_8)
|
||||
|
||||
if (tempFile.renameTo(persistentFile)) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
tempFile.copyTo(persistentFile, overwrite = true)
|
||||
tempFile.delete()
|
||||
Result.success(Unit)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSendHotfix(): Boolean {
|
||||
return readPersistentJson().map { it.optBoolean("send_hotfix", true) }.getOrDefault(true)
|
||||
}
|
||||
|
||||
suspend fun setSendHotfix(enabled: Boolean): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { JSONObject() }
|
||||
json.put("send_hotfix", enabled)
|
||||
return writePersistentJson(json)
|
||||
}
|
||||
|
||||
suspend fun getServerKey(): String {
|
||||
return readPersistentJson().map { it.optString("key", "") }.getOrDefault("")
|
||||
}
|
||||
|
||||
suspend fun setServerKey(key: String): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { JSONObject() }
|
||||
json.put("key", key)
|
||||
return writePersistentJson(json)
|
||||
}
|
||||
|
||||
suspend fun getTrailblazerConfig(): TrailblazerConfig {
|
||||
val json = readPersistentJson().getOrNull()
|
||||
val avatar = json?.optJSONObject("avatar")
|
||||
return TrailblazerConfig(
|
||||
gender = avatar?.optString("tb_gender", "Female") ?: "Female",
|
||||
tbPath = avatar?.optString("tb_path", "Destruction") ?: "Destruction",
|
||||
marchPath = avatar?.optString("march_path", "Preservation") ?: "Preservation"
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTrailblazerConfig(config: TrailblazerConfig): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { JSONObject() }
|
||||
val avatar = json.optJSONObject("avatar") ?: JSONObject().also { json.put("avatar", it) }
|
||||
avatar.put("tb_gender", config.gender)
|
||||
avatar.put("tb_path", config.tbPath)
|
||||
avatar.put("march_path", config.marchPath)
|
||||
return writePersistentJson(json)
|
||||
}
|
||||
|
||||
suspend fun applySceneTeleport(payload: SceneTeleportPayload): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { JSONObject() }
|
||||
val scene = json.optJSONObject("scene") ?: JSONObject().also { json.put("scene", it) }
|
||||
|
||||
scene.put("plane_id", payload.planeId)
|
||||
scene.put("x", payload.x)
|
||||
scene.put("y", payload.y)
|
||||
scene.put("z", payload.z)
|
||||
scene.put("calyx_x", payload.calyxX)
|
||||
scene.put("calyx_y", payload.calyxY)
|
||||
scene.put("calyx_z", payload.calyxZ)
|
||||
scene.put("map_layer", payload.mapLayer)
|
||||
scene.put("calyx_group_id", payload.calyxGroupId)
|
||||
scene.put("calyx_inst_id", payload.calyxInstId)
|
||||
scene.put("calyx_entity_id", payload.calyxEntityId)
|
||||
scene.put("calyx_prop_id", payload.calyxPropId)
|
||||
|
||||
return writePersistentJson(json)
|
||||
}
|
||||
|
||||
suspend fun listMiscFiles(): List<File> = withContext(Dispatchers.IO) {
|
||||
if (!miscDir.exists()) return@withContext emptyList()
|
||||
miscDir.listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun readMiscFile(name: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = File(miscDir, name)
|
||||
if (!file.exists()) return@withContext Result.failure(FileNotFoundException("$name not found"))
|
||||
Result.success(file.readText(StandardCharsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writeMiscFile(name: String, content: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = File(miscDir, name)
|
||||
file.parentFile?.mkdirs()
|
||||
val backup = File(miscDir, "$name.bak")
|
||||
if (file.exists()) file.copyTo(backup, overwrite = true)
|
||||
file.writeText(content, StandardCharsets.UTF_8)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
package com.catlas.escapepenacony.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import com.catlas.escapepenacony.model.PersistentHelper
|
||||
import com.catlas.escapepenacony.model.SceneConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import rikka.shizuku.Shizuku
|
||||
import java.io.File
|
||||
|
||||
enum class DirectModeType {
|
||||
NONE,
|
||||
ROOT,
|
||||
SHIZUKU
|
||||
}
|
||||
|
||||
object ShizukuHelper {
|
||||
const val REQUEST_CODE = 1001
|
||||
|
||||
fun isShizukuAvailable(): Boolean {
|
||||
return try {
|
||||
Shizuku.pingBinder()
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPermission(): Boolean {
|
||||
return try {
|
||||
if (!isShizukuAvailable()) return false
|
||||
if (Shizuku.isPreV11()) false else Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission() {
|
||||
try {
|
||||
if (isShizukuAvailable() && !hasPermission()) {
|
||||
Shizuku.requestPermission(REQUEST_CODE)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun execCommand(command: String): Pair<Int, String> {
|
||||
return try {
|
||||
val newProcessMethod = Shizuku::class.java.getDeclaredMethod(
|
||||
"newProcess",
|
||||
Array<String>::class.java,
|
||||
Array<String>::class.java,
|
||||
String::class.java
|
||||
)
|
||||
newProcessMethod.isAccessible = true
|
||||
val process = newProcessMethod.invoke(null, arrayOf("sh", "-c", command), null, null) as Process
|
||||
val output = process.inputStream.bufferedReader().use { it.readText().trim() }
|
||||
val exit = process.waitFor()
|
||||
Pair(exit, output)
|
||||
} catch (e: Exception) {
|
||||
Pair(-1, e.message ?: "Shizuku exec error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object StorageManager {
|
||||
|
||||
private const val PREFS_NAME = "escape_penacony_prefs"
|
||||
private const val KEY_PERSISTENT_URI = "persistent_uri"
|
||||
private const val KEY_SELECTED_LANG = "selected_language"
|
||||
|
||||
const val PPSR_PACKAGE_NAME = "com.afn.ppSR"
|
||||
const val PPSR_INTERNAL_PERSISTENT_PATH =
|
||||
"/data/data/com.afn.ppSR/files/server/build/build/misc/persistent.json"
|
||||
const val PPSR_INTERNAL_BACKUP_PATH =
|
||||
"/data/data/com.afn.ppSR/files/server/build/build/misc/persistent.json.bak"
|
||||
|
||||
fun savePersistentUri(context: Context, uri: Uri) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_PERSISTENT_URI, uri.toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getPersistentUri(context: Context): Uri? {
|
||||
val uriStr = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PERSISTENT_URI, null) ?: return null
|
||||
return try {
|
||||
Uri.parse(uriStr)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLanguage(context: Context, langCode: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_SELECTED_LANG, langCode)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getSavedLanguage(context: Context): String {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_SELECTED_LANG, "en") ?: "en"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Direct Access Engine (Root / Shizuku Direct Sync)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
suspend fun checkRootAccess(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "id"))
|
||||
val exit = process.waitFor()
|
||||
exit == 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkDirectMode(): DirectModeType = withContext(Dispatchers.IO) {
|
||||
if (checkRootAccess()) {
|
||||
return@withContext DirectModeType.ROOT
|
||||
}
|
||||
if (ShizukuHelper.hasPermission()) {
|
||||
return@withContext DirectModeType.SHIZUKU
|
||||
}
|
||||
DirectModeType.NONE
|
||||
}
|
||||
|
||||
suspend fun connectDirectMode(): Result<Pair<DirectModeType, String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Try Root first
|
||||
if (checkRootAccess()) {
|
||||
val dirCmd = "mkdir -p \"/data/data/$PPSR_PACKAGE_NAME/files/server/build/build/misc\""
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", dirCmd)).waitFor()
|
||||
|
||||
val checkCmd = "[ -f \"$PPSR_INTERNAL_PERSISTENT_PATH\" ] && echo 'EXISTS'"
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", checkCmd))
|
||||
val output = p.inputStream.bufferedReader().use { it.readText().trim() }
|
||||
p.waitFor()
|
||||
|
||||
if (output != "EXISTS") {
|
||||
val initJson = "{\n \"scene\": {\n \"plane_id\": 20001,\n \"floor_id\": 20001001,\n \"entry_id\": 2000101,\n \"pos_x\": 0,\n \"pos_y\": 0,\n \"pos_z\": 0\n }\n}"
|
||||
val createCmd = "echo '$initJson' > \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", createCmd)).waitFor()
|
||||
} else {
|
||||
val permCmd = "chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", permCmd)).waitFor()
|
||||
}
|
||||
return@withContext Result.success(Pair(DirectModeType.ROOT, PPSR_INTERNAL_PERSISTENT_PATH))
|
||||
}
|
||||
|
||||
// 2. Try Shizuku (Wireless Debugging)
|
||||
if (ShizukuHelper.isShizukuAvailable()) {
|
||||
if (!ShizukuHelper.hasPermission()) {
|
||||
ShizukuHelper.requestPermission()
|
||||
return@withContext Result.failure(Exception("NEED_SHIZUKU_PERMISSION"))
|
||||
}
|
||||
|
||||
// Verify file exists or create initial template
|
||||
val checkCmd = "run-as $PPSR_PACKAGE_NAME ls files/server/build/build/misc/persistent.json"
|
||||
val (exit, _) = ShizukuHelper.execCommand(checkCmd)
|
||||
if (exit != 0) {
|
||||
val initJson = "{\\n \\\"scene\\\": {\\n \\\"plane_id\\\": 20001,\\n \\\"floor_id\\\": 20001001,\\n \\\"entry_id\\\": 2000101,\\n \\\"pos_x\\\": 0,\\n \\\"pos_y\\\": 0,\\n \\\"pos_z\\\": 0\\n }\\n}"
|
||||
ShizukuHelper.execCommand("run-as $PPSR_PACKAGE_NAME mkdir -p files/server/build/build/misc")
|
||||
ShizukuHelper.execCommand("run-as $PPSR_PACKAGE_NAME sh -c \"echo '$initJson' > files/server/build/build/misc/persistent.json\"")
|
||||
}
|
||||
return@withContext Result.success(Pair(DirectModeType.SHIZUKU, PPSR_INTERNAL_PERSISTENT_PATH))
|
||||
}
|
||||
|
||||
Result.failure(Exception("NO_DIRECT_AVAILABLE"))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readConfigViaDirect(mode: DirectModeType): SceneConfig? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val jsonContent = when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
val cmd = "cat \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
val out = process.inputStream.bufferedReader().use { it.readText().trim() }
|
||||
process.waitFor()
|
||||
out
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val cmd = if (Shizuku.getUid() == 0) {
|
||||
"cat \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
} else {
|
||||
"run-as $PPSR_PACKAGE_NAME cat files/server/build/build/misc/persistent.json"
|
||||
}
|
||||
val (_, out) = ShizukuHelper.execCommand(cmd)
|
||||
out
|
||||
}
|
||||
else -> return@withContext null
|
||||
}
|
||||
if (jsonContent.isEmpty()) return@withContext null
|
||||
val root = JSONObject(jsonContent)
|
||||
val sceneObj = root.optJSONObject("scene") ?: return@withContext null
|
||||
SceneConfig.fromJsonObject(sceneObj)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writeConfigViaDirect(context: Context, mode: DirectModeType, newConfig: SceneConfig): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Read original content to preserve non-scene keys (avatar, hotfix, key, etc.)
|
||||
val orig = when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat \"$PPSR_INTERNAL_PERSISTENT_PATH\""))
|
||||
val t = p.inputStream.bufferedReader().use { it.readText().trim() }
|
||||
p.waitFor()
|
||||
t
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val cmd = if (Shizuku.getUid() == 0) {
|
||||
"cat \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
} else {
|
||||
"run-as $PPSR_PACKAGE_NAME cat files/server/build/build/misc/persistent.json"
|
||||
}
|
||||
val (_, out) = ShizukuHelper.execCommand(cmd)
|
||||
out
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Backup to .bak and local cache
|
||||
if (orig.isNotEmpty()) {
|
||||
try {
|
||||
val cacheBackup = File(context.cacheDir, "persistent_backup.json")
|
||||
cacheBackup.writeText(orig, Charsets.UTF_8)
|
||||
when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "cp \"$PPSR_INTERNAL_PERSISTENT_PATH\" \"$PPSR_INTERNAL_BACKUP_PATH\"")).waitFor()
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val bakCmd = if (Shizuku.getUid() == 0) {
|
||||
"cp \"$PPSR_INTERNAL_PERSISTENT_PATH\" \"$PPSR_INTERNAL_BACKUP_PATH\""
|
||||
} else {
|
||||
"run-as $PPSR_PACKAGE_NAME cp files/server/build/build/misc/persistent.json files/server/build/build/misc/persistent.json.bak"
|
||||
}
|
||||
ShizukuHelper.execCommand(bakCmd)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val updatedJson = PersistentHelper.updateJsonString(
|
||||
if (orig.isNotEmpty()) orig else "{}",
|
||||
newConfig
|
||||
)
|
||||
|
||||
when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
val cacheFile = File(context.cacheDir, "persistent_root_sync.json")
|
||||
cacheFile.writeText(updatedJson, Charsets.UTF_8)
|
||||
val copyCmd = "cp \"${cacheFile.absolutePath}\" \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", copyCmd))
|
||||
val exit = p.waitFor()
|
||||
if (exit == 0) Result.success(Unit) else Result.failure(Exception("Root write exited with $exit"))
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val cacheFile = File(context.cacheDir, "persistent_shizuku_sync.json")
|
||||
cacheFile.writeText(updatedJson, Charsets.UTF_8)
|
||||
val pushCmd = if (Shizuku.getUid() == 0) {
|
||||
"cat \"${cacheFile.absolutePath}\" > \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
} else {
|
||||
"cat \"${cacheFile.absolutePath}\" > /data/local/tmp/persistent_temp.json && chmod 666 /data/local/tmp/persistent_temp.json && run-as $PPSR_PACKAGE_NAME cp /data/local/tmp/persistent_temp.json files/server/build/build/misc/persistent.json"
|
||||
}
|
||||
val (exit, err) = ShizukuHelper.execCommand(pushCmd)
|
||||
if (exit == 0) Result.success(Unit) else Result.failure(Exception("Shizuku write error: $err"))
|
||||
}
|
||||
else -> Result.failure(Exception("No direct mode active"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreBackup(context: Context, mode: DirectModeType, uri: Uri?): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
val checkCmd = "[ -f \"$PPSR_INTERNAL_BACKUP_PATH\" ] && echo 'BAK_EXISTS'"
|
||||
val checkP = Runtime.getRuntime().exec(arrayOf("su", "-c", checkCmd))
|
||||
val checkOut = checkP.inputStream.bufferedReader().use { it.readText().trim() }
|
||||
checkP.waitFor()
|
||||
|
||||
if (checkOut == "BAK_EXISTS") {
|
||||
val restoreCmd = "cp \"$PPSR_INTERNAL_BACKUP_PATH\" \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", restoreCmd))
|
||||
val exit = p.waitFor()
|
||||
if (exit == 0) return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
val cacheBackup = File(context.cacheDir, "persistent_backup.json")
|
||||
if (cacheBackup.exists()) {
|
||||
val copyCmd = "cp \"${cacheBackup.absolutePath}\" \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", copyCmd))
|
||||
val exit = p.waitFor()
|
||||
if (exit == 0) return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
Result.failure(Exception("ไม่พบไฟล์สำรอง (.bak) / No backup file found"))
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val restoreCmd = if (Shizuku.getUid() == 0) {
|
||||
"cp \"$PPSR_INTERNAL_BACKUP_PATH\" \"$PPSR_INTERNAL_PERSISTENT_PATH\""
|
||||
} else {
|
||||
"run-as $PPSR_PACKAGE_NAME cp files/server/build/build/misc/persistent.json.bak files/server/build/build/misc/persistent.json"
|
||||
}
|
||||
val (exit, _) = ShizukuHelper.execCommand(restoreCmd)
|
||||
if (exit == 0) return@withContext Result.success(Unit)
|
||||
|
||||
val cacheBackup = File(context.cacheDir, "persistent_backup.json")
|
||||
if (cacheBackup.exists()) {
|
||||
val pushCmd = "cat \"${cacheBackup.absolutePath}\" > /data/local/tmp/persistent_temp.json && run-as $PPSR_PACKAGE_NAME cp /data/local/tmp/persistent_temp.json files/server/build/build/misc/persistent.json"
|
||||
val (pExit, _) = ShizukuHelper.execCommand(pushCmd)
|
||||
if (pExit == 0) return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
Result.failure(Exception("ไม่พบไฟล์สำรอง (.bak) / No backup file found"))
|
||||
}
|
||||
else -> {
|
||||
// SAF fallback
|
||||
if (uri == null) return@withContext Result.failure(Exception("ยังไม่ได้เลือกไฟล์ persistent.json"))
|
||||
val cacheBackup = File(context.cacheDir, "persistent_backup.json")
|
||||
if (!cacheBackup.exists()) return@withContext Result.failure(Exception("ไม่พบไฟล์สำรองในแคช / No cache backup found"))
|
||||
|
||||
val backupContent = cacheBackup.readText(Charsets.UTF_8)
|
||||
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
output.write(backupContent.toByteArray(Charsets.UTF_8))
|
||||
output.flush()
|
||||
} ?: return@withContext Result.failure(Exception("Failed to open output stream"))
|
||||
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restartPpsrServer(mode: DirectModeType): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val restartCmd = "am force-stop $PPSR_PACKAGE_NAME && sleep 0.5 && am start -n $PPSR_PACKAGE_NAME/.MainActivity"
|
||||
when (mode) {
|
||||
DirectModeType.ROOT -> {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", restartCmd))
|
||||
val exit = process.waitFor()
|
||||
if (exit == 0) Result.success(Unit) else Result.failure(Exception("Restart exited with $exit"))
|
||||
}
|
||||
DirectModeType.SHIZUKU -> {
|
||||
val (exit, err) = ShizukuHelper.execCommand(restartCmd)
|
||||
if (exit == 0) Result.success(Unit) else Result.failure(Exception("Shizuku restart error: $err"))
|
||||
}
|
||||
else -> Result.failure(Exception("No direct mode active"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SAF / Shared Storage Access Engine (Non-Root fallback)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
suspend fun readCurrentConfig(context: Context, uri: Uri): SceneConfig? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
|
||||
?: return@withContext null
|
||||
val root = JSONObject(content)
|
||||
val sceneObj = root.optJSONObject("scene") ?: return@withContext null
|
||||
SceneConfig.fromJsonObject(sceneObj)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun applySceneConfig(context: Context, uri: Uri, newConfig: SceneConfig): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val originalContent = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: "{}"
|
||||
|
||||
try {
|
||||
val backupFile = File(context.cacheDir, "persistent_backup.json")
|
||||
backupFile.writeText(originalContent, Charsets.UTF_8)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
val updatedJson = PersistentHelper.updateJsonString(originalContent, newConfig)
|
||||
|
||||
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
output.write(updatedJson.toByteArray(Charsets.UTF_8))
|
||||
output.flush()
|
||||
} ?: return@withContext Result.failure(Exception("Failed to open output stream"))
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportTemplateJson(context: Context, newConfig: SceneConfig): String {
|
||||
val root = JSONObject()
|
||||
root.put("scene", newConfig.toJsonObject())
|
||||
return root.toString(2)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.catlas.escapepenacony.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import com.catlas.escapepenacony.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class ReleaseInfo(
|
||||
val tagName: String,
|
||||
val releaseTitle: String,
|
||||
val changelog: String,
|
||||
val apkDownloadUrl: String,
|
||||
val isNewer: Boolean
|
||||
)
|
||||
|
||||
object UpdateManager {
|
||||
|
||||
private const val GITHUB_RELEASES_API =
|
||||
"https://api.github.com/repos/CAtlas0784/Escape-penacony/releases/latest"
|
||||
|
||||
private const val GITHUB_RAW_SCENES_URL =
|
||||
"https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/testing/scenes.json"
|
||||
private const val GITHUB_RAW_SCENES_FALLBACK =
|
||||
"https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/main/scenes.json"
|
||||
|
||||
suspend fun checkForAppUpdate(currentVersion: String = BuildConfig.VERSION_NAME): Result<ReleaseInfo?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(GITHUB_RELEASES_API)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
conn.setRequestProperty("Accept", "application/vnd.github.v3+json")
|
||||
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val json = JSONObject(body)
|
||||
val tagName = json.getString("tag_name")
|
||||
val title = json.optString("name", tagName)
|
||||
val changelog = json.optString("body", "No changelog provided.")
|
||||
|
||||
var downloadUrl = ""
|
||||
val assets = json.optJSONArray("assets")
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val asset = assets.getJSONObject(i)
|
||||
val name = asset.optString("name", "")
|
||||
if (name.endsWith(".apk", ignoreCase = true)) {
|
||||
downloadUrl = asset.getString("browser_download_url")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isNewer = isNewerVersion(currentVersion, tagName)
|
||||
Result.success(
|
||||
ReleaseInfo(
|
||||
tagName = tagName,
|
||||
releaseTitle = title,
|
||||
changelog = changelog,
|
||||
apkDownloadUrl = downloadUrl,
|
||||
isNewer = isNewer
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.success(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadApk(
|
||||
context: Context,
|
||||
downloadUrl: String,
|
||||
onProgress: (Int) -> Unit
|
||||
): Result<File> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(downloadUrl)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 15000
|
||||
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
|
||||
val fileLength = conn.contentLength
|
||||
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir
|
||||
val apkFile = File(dir, "Escape-penacony-update.apk")
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(apkFile).use { output ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total: Long = 0
|
||||
var count: Int
|
||||
while (input.read(buffer).also { count = it } != -1) {
|
||||
total += count
|
||||
if (fileLength > 0) {
|
||||
val progress = ((total * 100) / fileLength).toInt()
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(apkFile)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun promptInstallApk(context: Context, apkFile: File) {
|
||||
// Android 8.0+ Check Unknown App Sources permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
val settingsIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(settingsIntent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val apkUri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
|
||||
val installIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(installIntent)
|
||||
}
|
||||
|
||||
suspend fun fetchLatestScenesJson(): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val primaryUrl = URL(GITHUB_RAW_SCENES_URL)
|
||||
var conn = primaryUrl.openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
|
||||
if (conn.responseCode != 200) {
|
||||
val fallbackUrl = URL(GITHUB_RAW_SCENES_FALLBACK)
|
||||
conn = fallbackUrl.openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
}
|
||||
|
||||
if (conn.responseCode == 200) {
|
||||
val content = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
Result.success(content)
|
||||
} else {
|
||||
Result.failure(Exception("HTTP error ${conn.responseCode}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNewerVersion(current: String, latest: String): Boolean {
|
||||
val c = current.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val l = latest.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val maxLen = maxOf(c.size, l.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val cv = c.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.catlas.escapepenacony.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class UpdateInfo(
|
||||
val currentVersion: String,
|
||||
val latestVersion: String,
|
||||
val availableVersions: List<String>,
|
||||
val hasUpdate: Boolean
|
||||
)
|
||||
|
||||
class UpdateRepository(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("ppsr_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
const val VERSIONS_URL = "https://file.garden/aewBviVxx2XDWgM-/VERSIONS"
|
||||
const val PREF_INSTALLED_VERSION = "installed_server_version"
|
||||
const val CONNECT_TIMEOUT_MS = 15000
|
||||
const val READ_TIMEOUT_MS = 15000
|
||||
}
|
||||
|
||||
fun getInstalledVersion(): String {
|
||||
return prefs.getString(PREF_INSTALLED_VERSION, "") ?: ""
|
||||
}
|
||||
|
||||
fun setInstalledVersion(version: String) {
|
||||
prefs.edit().putString(PREF_INSTALLED_VERSION, version).apply()
|
||||
}
|
||||
|
||||
suspend fun checkUpdate(): Result<UpdateInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val versions = fetchAvailableVersions().getOrThrow()
|
||||
if (versions.isEmpty()) {
|
||||
return@withContext Result.failure(IllegalStateException("No versions found from server"))
|
||||
}
|
||||
|
||||
val latestVersion = versions.last()
|
||||
val installed = getInstalledVersion()
|
||||
val hasUpdate = installed.isBlank() || installed != latestVersion
|
||||
|
||||
Result.success(
|
||||
UpdateInfo(
|
||||
currentVersion = installed,
|
||||
latestVersion = latestVersion,
|
||||
availableVersions = versions,
|
||||
hasUpdate = hasUpdate
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAvailableVersions(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(VERSIONS_URL)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "ppSR-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("HTTP error ${conn.responseCode}: ${conn.responseMessage}")
|
||||
)
|
||||
}
|
||||
|
||||
val rawText = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val list = rawText.split(Regex("[\\r\\n,]+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
Result.success(list)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDownloadUrl(version: String, filename: String = "build.zip"): String {
|
||||
val sanitizedVersion = version.trim()
|
||||
if (sanitizedVersion.contains("/") || sanitizedVersion.contains("\\") || sanitizedVersion.contains("..")) {
|
||||
throw IllegalArgumentException("Invalid version format: traversal characters detected")
|
||||
}
|
||||
return "$BASE_URL$sanitizedVersion/$filename"
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(
|
||||
version: String,
|
||||
onProgress: (Float, String) -> Unit
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val tempZip = File(context.cacheDir, "_temp_build_${System.currentTimeMillis()}.zip")
|
||||
val stagingDir = File(context.filesDir, "server/staging")
|
||||
val targetServerDir = File(context.filesDir, "server/build/build")
|
||||
val miscBackupFile = File(context.filesDir, "server/persistent_backup.json")
|
||||
val configBackupFile = File(context.filesDir, "server/config_backup.json")
|
||||
|
||||
try {
|
||||
onProgress(0.05f, "Connecting to download server...")
|
||||
val downloadUrl = buildDownloadUrl(version, "build.zip")
|
||||
val url = URL(downloadUrl)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "ppSR-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Failed to download package: HTTP ${conn.responseCode}")
|
||||
)
|
||||
}
|
||||
|
||||
val contentLength = conn.contentLengthLong
|
||||
val inputStream = BufferedInputStream(conn.inputStream)
|
||||
val outputStream = FileOutputStream(tempZip)
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var totalBytesRead = 0L
|
||||
var bytesRead: Int
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = 0.1f + (totalBytesRead.toFloat() / contentLength.toFloat()) * 0.5f
|
||||
val mbRead = totalBytesRead / (1024 * 1024f)
|
||||
val mbTotal = contentLength / (1024 * 1024f)
|
||||
onProgress(progress, String.format("Downloading: %.1f MB / %.1f MB", mbRead, mbTotal))
|
||||
} else {
|
||||
onProgress(0.3f, "Downloading package...")
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
|
||||
onProgress(0.65f, "Backing up user configuration...")
|
||||
val existingPersistent = File(targetServerDir, "misc/persistent.json")
|
||||
if (existingPersistent.exists()) {
|
||||
existingPersistent.copyTo(miscBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
val existingConfig = File(targetServerDir, "misc/config.json")
|
||||
if (existingConfig.exists()) {
|
||||
existingConfig.copyTo(configBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.70f, "Preparing staging directory...")
|
||||
if (stagingDir.exists()) {
|
||||
stagingDir.deleteRecursively()
|
||||
}
|
||||
stagingDir.mkdirs()
|
||||
|
||||
onProgress(0.75f, "Extracting server archive...")
|
||||
extractZipSafely(tempZip, stagingDir)
|
||||
|
||||
onProgress(0.85f, "Restoring user data...")
|
||||
// The zip extracts to stagingDir/build/...
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
|
||||
|
||||
if (miscBackupFile.exists()) {
|
||||
miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true)
|
||||
}
|
||||
|
||||
if (configBackupFile.exists()) {
|
||||
configBackupFile.copyTo(File(stagedMiscDir, "config.json"), overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.92f, "Finalizing installation...")
|
||||
if (targetServerDir.exists()) {
|
||||
targetServerDir.deleteRecursively()
|
||||
}
|
||||
targetServerDir.parentFile?.mkdirs()
|
||||
|
||||
val success = effectiveStagedDir.renameTo(targetServerDir)
|
||||
if (!success) {
|
||||
// Fallback copy if atomic rename fails across partitions
|
||||
copyDirectory(effectiveStagedDir, targetServerDir)
|
||||
effectiveStagedDir.deleteRecursively()
|
||||
}
|
||||
|
||||
setInstalledVersion(version)
|
||||
onProgress(1.0f, "Installation complete!")
|
||||
Result.success(Unit)
|
||||
|
||||
} catch (e: Exception) {
|
||||
onProgress(0f, "Installation error: ${e.message}")
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
if (tempZip.exists()) tempZip.delete()
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZipSafely(zipFile: File, outputDir: File) {
|
||||
ZipInputStream(zipFile.inputStream().buffered()).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
|
||||
// Prevent zip slip traversal vulnerability
|
||||
val canonicalDestPath = outputDir.canonicalPath
|
||||
val canonicalNewFilePath = newFile.canonicalPath
|
||||
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
|
||||
canonicalNewFilePath != canonicalDestPath
|
||||
) {
|
||||
throw SecurityException("Zip Slip detected: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
} else {
|
||||
newFile.parentFile?.mkdirs()
|
||||
FileOutputStream(newFile).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirectory(source: File, target: File) {
|
||||
if (source.isDirectory) {
|
||||
if (!target.exists()) target.mkdirs()
|
||||
source.list()?.forEach { file ->
|
||||
copyDirectory(File(source, file), File(target, file))
|
||||
}
|
||||
} else {
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.catlas.escapepenacony.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.catlas.escapepenacony.MainActivity
|
||||
import com.catlas.escapepenacony.R
|
||||
import com.chaquo.python.Python
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
enum class ServerStatus {
|
||||
STOPPED,
|
||||
STARTING,
|
||||
RUNNING,
|
||||
STOPPING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
class ServerLogReceiver {
|
||||
fun onServerLog(msg: String) {
|
||||
ServerService.appendLog(msg)
|
||||
}
|
||||
}
|
||||
|
||||
class ServerService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + Job())
|
||||
private var serverThread: Thread? = null
|
||||
private var probeJob: Job? = null
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.catlas.escapepenacony.START"
|
||||
const val ACTION_STOP = "com.catlas.escapepenacony.STOP"
|
||||
const val ACTION_RESTART = "com.catlas.escapepenacony.RESTART"
|
||||
|
||||
private const val NOTIFICATION_ID = 21000
|
||||
private const val CHANNEL_ID = "ppsr_server_channel"
|
||||
|
||||
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
|
||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||
|
||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||
|
||||
private val maxLogs = 500
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun appendLog(message: String) {
|
||||
val timestamp = timeFormat.format(Date())
|
||||
val formatted = "[$timestamp] $message"
|
||||
_logs.value = (_logs.value + formatted).takeLast(maxLogs)
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
_logs.value = emptyList()
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply {
|
||||
action = ACTION_START
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply {
|
||||
action = ACTION_RESTART
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
startServerExecution()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopServerExecution()
|
||||
}
|
||||
ACTION_RESTART -> {
|
||||
serviceScope.launch {
|
||||
stopServerExecution()
|
||||
delay(1500)
|
||||
startServerExecution()
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startServerExecution() {
|
||||
if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) {
|
||||
return
|
||||
}
|
||||
|
||||
_serverStatus.value = ServerStatus.STARTING
|
||||
startForegroundNotification("Starting server...")
|
||||
|
||||
val ppsrFile = File(filesDir, "server/build/build/ppsr.pyc")
|
||||
if (!ppsrFile.exists()) {
|
||||
appendLog("[Error] Server binaries not found at: ${ppsrFile.absolutePath}")
|
||||
appendLog("[Error] Please check for updates or install the server first.")
|
||||
_serverStatus.value = ServerStatus.ERROR
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
appendLog("[System] Launching ppSR Python 3.14 Server...")
|
||||
|
||||
// Start readiness check in background
|
||||
probeJob?.cancel()
|
||||
probeJob = serviceScope.launch {
|
||||
var attempts = 0
|
||||
while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 30) {
|
||||
delay(1000)
|
||||
attempts++
|
||||
if (isPortOpen("127.0.0.1", 21000)) {
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("Server running on 127.0.0.1:21000")
|
||||
appendLog("[System] Dispatch server is ready on 127.0.0.1:21000")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serverThread = Thread({
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val runPpsr = py.getModule("run_ppsr")
|
||||
val logReceiver = ServerLogReceiver()
|
||||
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
|
||||
} catch (e: Exception) {
|
||||
appendLog("[Python Error] ${e.localizedMessage ?: e.message}")
|
||||
} finally {
|
||||
probeJob?.cancel()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
appendLog("[System] Server stopped.")
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
}, "ppSR-PythonServer").apply {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopServerExecution() {
|
||||
if (_serverStatus.value == ServerStatus.STOPPED) return
|
||||
_serverStatus.value = ServerStatus.STOPPING
|
||||
appendLog("[System] Stopping server...")
|
||||
updateNotification("Stopping server...")
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
if (Python.isStarted()) {
|
||||
val py = Python.getInstance()
|
||||
val runPpsr = py.getModule("run_ppsr")
|
||||
runPpsr.callAttr("kill_server_process")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
appendLog("[Stop Error] ${e.localizedMessage ?: e.message}")
|
||||
}
|
||||
|
||||
delay(1200)
|
||||
serverThread?.interrupt()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(host: String, port: Int): Boolean {
|
||||
return try {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress(host, port), 400)
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"ppSR Server Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows status of local ppSR Python server"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("ppSR Standalone Server")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun startForegroundNotification(text: String) {
|
||||
val notification = buildNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
probeJob?.cancel()
|
||||
serverThread?.interrupt()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,3 +15,7 @@ val CombatGreen = Color(0xFF00E676)
|
||||
val ExploreAmber = Color(0xFFFFAB00)
|
||||
val BetaPurple = Color(0xFFE040FB)
|
||||
val BorderSubtle = Color(0xFF2C2F40)
|
||||
|
||||
val BgDark = DarkBackground
|
||||
val BgCard = DarkSurface
|
||||
val BgCardLight = DarkSurfaceElevated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import os
|
||||
|
||||
def load_pyc(path, module_name):
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"PYC file not found at absolute path: {path}")
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load spec for module {module_name} from {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import tarfile
|
||||
import urllib.request
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
import re
|
||||
|
||||
# Primary PyPI base URL and mirrors for China fallback
|
||||
PYPI_BASES = [
|
||||
"https://pypi.org/pypi", # Official PyPI
|
||||
"https://pypi.tuna.tsinghua.edu.cn/pypi", # Tsinghua (TUNA)
|
||||
"https://mirrors.aliyun.com/pypi", # Aliyun
|
||||
]
|
||||
|
||||
def _parse_package_spec(package_spec):
|
||||
match = re.match(r"^([a-zA-Z0-9_\-\.]+)(.*)$", package_spec.strip())
|
||||
if match:
|
||||
return match.group(1), match.group(2).strip()
|
||||
return package_spec, ""
|
||||
|
||||
def _fetch_pypi_data(clean_name):
|
||||
"""Attempt to fetch metadata JSON from PyPI and mirrors."""
|
||||
for base_url in PYPI_BASES:
|
||||
url = f"{base_url}/{clean_name}/json"
|
||||
try:
|
||||
print(f"Fetching metadata from: {base_url}…")
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch metadata from {base_url}: {e}")
|
||||
return None
|
||||
|
||||
def _download_file(file_info_urls, dest_path):
|
||||
"""Download package file with mirror URL rewriting if official PyPI fails."""
|
||||
# List of candidate download URLs (original URL + mirror URL replacements)
|
||||
for file_info in file_info_urls:
|
||||
original_url = file_info.get("url")
|
||||
if not original_url:
|
||||
continue
|
||||
|
||||
# Generate candidate download URLs for this file
|
||||
candidate_urls = [original_url]
|
||||
|
||||
# Rewrite official PyPI file URLs to mirror locations if needed
|
||||
if "files.pythonhosted.org" in original_url:
|
||||
candidate_urls.append(
|
||||
original_url.replace("https://files.pythonhosted.org", "https://pypi.tuna.tsinghua.edu.cn")
|
||||
)
|
||||
candidate_urls.append(
|
||||
original_url.replace("https://files.pythonhosted.org", "https://mirrors.aliyun.com/pypi")
|
||||
)
|
||||
|
||||
for dl_url in candidate_urls:
|
||||
try:
|
||||
print(f"Downloading from {dl_url}…")
|
||||
dl_req = urllib.request.Request(dl_url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(dl_req, timeout=30) as dl_resp, open(dest_path, "wb") as out_file:
|
||||
out_file.write(dl_resp.read())
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Download failed from {dl_url}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def install_package(package_name, target_dir):
|
||||
try:
|
||||
clean_name, version_spec = _parse_package_spec(package_name)
|
||||
print(f"Resolving PyPI metadata for {clean_name} (spec: '{version_spec}')…")
|
||||
|
||||
data = _fetch_pypi_data(clean_name)
|
||||
if not data:
|
||||
print(f"Could not retrieve PyPI metadata for {clean_name} from any mirror.")
|
||||
return False
|
||||
|
||||
urls = data.get("urls", [])
|
||||
|
||||
if version_spec.startswith("=="):
|
||||
req_version = version_spec.lstrip("=").strip()
|
||||
releases = data.get("releases", {})
|
||||
if req_version in releases:
|
||||
urls = releases[req_version]
|
||||
else:
|
||||
print(f"Version {req_version} not found on PyPI for {clean_name}")
|
||||
return False
|
||||
|
||||
target_file_infos = []
|
||||
|
||||
# 1. Prefer pure-Python wheels
|
||||
for file_info in urls:
|
||||
filename = file_info.get("filename", "")
|
||||
if filename.endswith("py3-none-any.whl") or filename.endswith("py2.py3-none-any.whl"):
|
||||
target_file_infos.append(file_info)
|
||||
break
|
||||
|
||||
# 2. Fallback to source distribution
|
||||
if not target_file_infos:
|
||||
for file_info in urls:
|
||||
if file_info.get("packagetype") == "sdist":
|
||||
target_file_infos.append(file_info)
|
||||
break
|
||||
|
||||
if not target_file_infos:
|
||||
print(f"No compatible pure-Python release found for {clean_name}")
|
||||
return False
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
safe_filename = clean_name.replace("/", "_")
|
||||
dest_path = os.path.join(target_dir, f"_dl_{safe_filename}")
|
||||
|
||||
if not _download_file(target_file_infos, dest_path):
|
||||
print(f"Failed to download payload for {clean_name} from all sources.")
|
||||
return False
|
||||
|
||||
# Extract package files
|
||||
if dest_path.endswith(".whl") or dest_path.endswith(".zip") or target_file_infos[0].get("filename", "").endswith((".whl", ".zip")):
|
||||
with zipfile.ZipFile(dest_path, "r") as zip_ref:
|
||||
zip_ref.extractall(target_dir)
|
||||
else:
|
||||
with tarfile.open(dest_path, "r:gz") as tar_ref:
|
||||
tar_ref.extractall(target_dir)
|
||||
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
|
||||
# Handle 'src' layout if present
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
if os.path.basename(root) == "src":
|
||||
for d in dirs:
|
||||
src_pkg = os.path.join(root, d)
|
||||
dest_pkg = os.path.join(target_dir, d)
|
||||
if not os.path.exists(dest_pkg):
|
||||
shutil.move(src_pkg, dest_pkg)
|
||||
|
||||
# Cleanup __pycache__
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
for d in list(dirs):
|
||||
if d == "__pycache__":
|
||||
shutil.rmtree(os.path.join(root, d), ignore_errors=True)
|
||||
|
||||
if target_dir not in sys.path:
|
||||
sys.path.insert(0, target_dir)
|
||||
|
||||
print(f"Successfully installed {clean_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to install {package_name}: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
@@ -0,0 +1,200 @@
|
||||
import sys
|
||||
import os
|
||||
import builtins
|
||||
import time
|
||||
import _thread
|
||||
import gc
|
||||
import socket
|
||||
import struct
|
||||
import warnings
|
||||
|
||||
# === GLOBAL WARNING SUPPRESSION (very early) ===
|
||||
try:
|
||||
from marshmallow.warnings import RemovedInMarshmallow4Warning
|
||||
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||
warnings.simplefilter("ignore", category=RemovedInMarshmallow4Warning)
|
||||
print("[Shim] Global warning suppression activated")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_active_server_instance = None
|
||||
|
||||
# --- 1. Stream Redirector for Android UI ---
|
||||
class AndroidLogStream:
|
||||
def __init__(self, activity_context, original_stream):
|
||||
self.activity_context = activity_context
|
||||
self.original_stream = original_stream
|
||||
self.buffer = ""
|
||||
|
||||
def write(self, message):
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.write(message)
|
||||
except Exception:
|
||||
pass
|
||||
self.buffer += message
|
||||
while "\n" in self.buffer:
|
||||
line, self.buffer = self.buffer.split("\n", 1)
|
||||
clean_line = line.strip()
|
||||
if clean_line and self.activity_context:
|
||||
try:
|
||||
self.activity_context.onServerLog(clean_line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
if self.buffer.strip() and self.activity_context:
|
||||
try:
|
||||
self.activity_context.onServerLog(self.buffer.strip())
|
||||
self.buffer = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- 2. Headless Input Mock ---
|
||||
def _mock_input(prompt=""):
|
||||
if prompt:
|
||||
print(f"[CLI]: {prompt}")
|
||||
time.sleep(2)
|
||||
return ""
|
||||
|
||||
builtins.input = _mock_input
|
||||
|
||||
# --- 3. Minimal Marshmallow Info ---
|
||||
try:
|
||||
import marshmallow
|
||||
print(f"[Shim] marshmallow {marshmallow.__version__}")
|
||||
except Exception:
|
||||
print("[Shim] marshmallow not available")
|
||||
|
||||
# --- 4. Shutdown Logic ---
|
||||
def kill_server_process():
|
||||
global _active_server_instance
|
||||
print("[Shutdown]: Force-stopping server execution...")
|
||||
|
||||
if _active_server_instance is not None:
|
||||
try:
|
||||
if hasattr(_active_server_instance, 'shutdown'):
|
||||
_active_server_instance.shutdown()
|
||||
if hasattr(_active_server_instance, 'server_close'):
|
||||
_active_server_instance.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
sock = getattr(_active_server_instance, 'socket', None)
|
||||
if sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
_active_server_instance = None
|
||||
|
||||
try:
|
||||
for obj in gc.get_objects():
|
||||
if isinstance(obj, socket.socket):
|
||||
try:
|
||||
obj.shutdown(socket.SHUT_RDWR)
|
||||
obj.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(1.2)
|
||||
|
||||
for _ in range(5):
|
||||
try:
|
||||
_thread.interrupt_main()
|
||||
time.sleep(0.2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- 5. Main Execution Entrypoint ---
|
||||
def run_server(ppsr_path, activity_context=None):
|
||||
if activity_context is not None:
|
||||
sys.stdout = AndroidLogStream(activity_context, sys.__stdout__)
|
||||
sys.stderr = AndroidLogStream(activity_context, sys.__stderr__)
|
||||
|
||||
# Extra safety - re-apply on every start
|
||||
try:
|
||||
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ppsr_dir = os.path.dirname(os.path.abspath(ppsr_path))
|
||||
misc_dir = os.path.join(ppsr_dir, "misc")
|
||||
|
||||
if os.path.exists(ppsr_dir):
|
||||
os.chdir(ppsr_dir)
|
||||
print(f"Working directory locked: {os.getcwd()}")
|
||||
|
||||
for path in [ppsr_dir, misc_dir]:
|
||||
if os.path.exists(path) and path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
# --- Werkzeug Patch ---
|
||||
try:
|
||||
import werkzeug.serving
|
||||
_orig_make_server = werkzeug.serving.make_server
|
||||
|
||||
def _patched_make_server(*args, **kwargs):
|
||||
global _active_server_instance
|
||||
kwargs.pop('options', None)
|
||||
server = _orig_make_server(*args, **kwargs)
|
||||
|
||||
try:
|
||||
if hasattr(server, 'socket') and server.socket:
|
||||
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_active_server_instance = server
|
||||
print("[Server] Server instance patched and ready")
|
||||
return server
|
||||
|
||||
werkzeug.serving.make_server = _patched_make_server
|
||||
print("[Patch] Werkzeug make_server patched successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] Werkzeug patch: {e}")
|
||||
|
||||
print(f"Loading bytecode target: {ppsr_path}...")
|
||||
|
||||
with open(ppsr_path, "rb") as f:
|
||||
code_bytes = f.read()
|
||||
|
||||
try:
|
||||
import marshal
|
||||
code_obj = marshal.loads(code_bytes[16:])
|
||||
except Exception:
|
||||
code_obj = compile(code_bytes, ppsr_path, "exec")
|
||||
|
||||
main_globals = {
|
||||
"__name__": "__main__",
|
||||
"__file__": ppsr_path,
|
||||
"__doc__": None,
|
||||
"sys": sys,
|
||||
"os": os,
|
||||
"input": _mock_input
|
||||
}
|
||||
|
||||
try:
|
||||
exec(code_obj, main_globals)
|
||||
except KeyboardInterrupt:
|
||||
print("[System]: Server loop interrupted cleanly.")
|
||||
except Exception as e:
|
||||
print(f"[System]: Server terminated: {e}")
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.catlas.escapepenacony
|
||||
|
||||
import com.catlas.escapepenacony.data.SceneTeleportPayload
|
||||
import com.catlas.escapepenacony.data.TrailblazerConfig
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class UpdateAndSceneTest {
|
||||
|
||||
// --- 1. Version Parsing Logic Tests ---
|
||||
|
||||
private fun parseVersions(raw: String): List<String> {
|
||||
val sanitized = if (raw.startsWith("\uFEFF")) raw.substring(1) else raw
|
||||
return sanitized.split(Regex("[\\r\\n,]+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun buildDownloadUrl(baseUrl: String, version: String, filename: String = "build.zip"): String {
|
||||
val sanitizedVersion = version.trim()
|
||||
if (sanitizedVersion.contains("/") || sanitizedVersion.contains("\\") || sanitizedVersion.contains("..")) {
|
||||
throw IllegalArgumentException("Invalid version format: traversal characters detected")
|
||||
}
|
||||
return "$baseUrl$sanitizedVersion/$filename"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVersionParsing_CRLF_LF_Comma_Duplicates() {
|
||||
val sample = "\uFEFF4.3.5X\r\n4.4.5X\n4.5.5X, 4.4.5X\r\n 4.6.5X \r\n\r\n"
|
||||
val parsed = parseVersions(sample)
|
||||
|
||||
assertEquals(4, parsed.size)
|
||||
assertEquals("4.3.5X", parsed[0])
|
||||
assertEquals("4.4.5X", parsed[1])
|
||||
assertEquals("4.5.5X", parsed[2])
|
||||
assertEquals("4.6.5X", parsed[3])
|
||||
assertEquals("4.6.5X", parsed.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVersionParsing_SuffixesPreserved() {
|
||||
val sample = "4.6.5X\n4.7.0-BETA_1\n4.7.0-RC2"
|
||||
val parsed = parseVersions(sample)
|
||||
|
||||
assertEquals("4.7.0-RC2", parsed.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVersionParsing_EmptyOrWhitespace() {
|
||||
val sample = " \r\n \n \r\n"
|
||||
val parsed = parseVersions(sample)
|
||||
|
||||
assertTrue(parsed.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBuildDownloadUrl_Valid() {
|
||||
val baseUrl = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
val url = buildDownloadUrl(baseUrl, "4.6.5X")
|
||||
assertEquals("https://file.garden/aewBviVxx2XDWgM-/4.6.5X/build.zip", url)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testBuildDownloadUrl_RejectsTraversal_Slash() {
|
||||
val baseUrl = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
buildDownloadUrl(baseUrl, "4.6.5X/hack")
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testBuildDownloadUrl_RejectsTraversal_DotDot() {
|
||||
val baseUrl = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
buildDownloadUrl(baseUrl, "../etc")
|
||||
}
|
||||
|
||||
// --- 2. Persistent.json Transformation Tests ---
|
||||
|
||||
@Test
|
||||
fun testPersistentTransformation_PreservesOtherKeys() {
|
||||
val originalJson = """
|
||||
{
|
||||
"send_hotfix": false,
|
||||
"key": "secret123",
|
||||
"avatar": {
|
||||
"tb_gender": "Male",
|
||||
"tb_path": "Harmony",
|
||||
"march_path": "Hunt"
|
||||
},
|
||||
"scene": {
|
||||
"plane_id": 10000,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"z": 30
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = JSONObject(originalJson)
|
||||
val payload = SceneTeleportPayload(
|
||||
planeId = 20461,
|
||||
x = 12080,
|
||||
y = 393572,
|
||||
z = 322230,
|
||||
calyxX = 12080,
|
||||
calyxY = 393572,
|
||||
calyxZ = 324030,
|
||||
mapLayer = 2,
|
||||
calyxGroupId = 186,
|
||||
calyxInstId = 300001,
|
||||
calyxEntityId = 1337,
|
||||
calyxPropId = 808
|
||||
)
|
||||
|
||||
// Apply scene
|
||||
val sceneObj = root.optJSONObject("scene") ?: JSONObject().also { root.put("scene", it) }
|
||||
sceneObj.put("plane_id", payload.planeId)
|
||||
sceneObj.put("x", payload.x)
|
||||
sceneObj.put("y", payload.y)
|
||||
sceneObj.put("z", payload.z)
|
||||
sceneObj.put("calyx_x", payload.calyxX)
|
||||
sceneObj.put("calyx_y", payload.calyxY)
|
||||
sceneObj.put("calyx_z", payload.calyxZ)
|
||||
sceneObj.put("map_layer", payload.mapLayer)
|
||||
sceneObj.put("calyx_group_id", payload.calyxGroupId)
|
||||
sceneObj.put("calyx_inst_id", payload.calyxInstId)
|
||||
sceneObj.put("calyx_entity_id", payload.calyxEntityId)
|
||||
sceneObj.put("calyx_prop_id", payload.calyxPropId)
|
||||
|
||||
// Verify other keys strictly preserved
|
||||
assertFalse(root.getBoolean("send_hotfix"))
|
||||
assertEquals("secret123", root.getString("key"))
|
||||
assertEquals("Male", root.getJSONObject("avatar").getString("tb_gender"))
|
||||
assertEquals("Harmony", root.getJSONObject("avatar").getString("tb_path"))
|
||||
|
||||
// Verify updated scene keys
|
||||
val updatedScene = root.getJSONObject("scene")
|
||||
assertEquals(20461, updatedScene.getInt("plane_id"))
|
||||
assertEquals(12080, updatedScene.getInt("x"))
|
||||
assertEquals(393572, updatedScene.getInt("y"))
|
||||
assertEquals(322230, updatedScene.getInt("z"))
|
||||
assertEquals(2, updatedScene.getInt("map_layer"))
|
||||
assertEquals(186, updatedScene.getInt("calyx_group_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTrailblazerConfigUpdate() {
|
||||
val root = JSONObject("""{"avatar": {}}""")
|
||||
val avatar = root.getJSONObject("avatar")
|
||||
|
||||
val newConfig = TrailblazerConfig(gender = "Female", tbPath = "Elation", marchPath = "Preservation")
|
||||
avatar.put("tb_gender", newConfig.gender)
|
||||
avatar.put("tb_path", newConfig.tbPath)
|
||||
avatar.put("march_path", newConfig.marchPath)
|
||||
|
||||
assertEquals("Female", avatar.getString("tb_gender"))
|
||||
assertEquals("Elation", avatar.getString("tb_path"))
|
||||
assertEquals("Preservation", avatar.getString("march_path"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user