Compare commits
10
Commits
249c922a3e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab63f506c8 | ||
|
|
96ec5e950e | ||
|
|
4194259a32 | ||
|
|
c7af4069c9 | ||
|
|
f439733781 | ||
|
|
ced1b6a780 | ||
|
|
b481d35adb | ||
|
|
9ace7a2c16 | ||
|
|
2de9a13c89 | ||
|
|
e7e639b5a2 |
No files matched your search
@@ -11,3 +11,5 @@ local.properties
|
||||
/app/build
|
||||
|
||||
*.apk
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,95 +1,158 @@
|
||||
# Escape penacony standalone 📱✨
|
||||
## ppSR Scene & Map Teleporter - Native Android Application
|
||||
# ppSR & Escape Penacony 📱✨
|
||||
## Native Android All-in-One Server & Map Teleporter
|
||||
|
||||
แอปพลิเคชัน Android Standalone สำหรับจัดการและเทเลพอร์ตแผนที่ในเซิร์ฟเวอร์จำลอง **ppSR (Project Star Rail)** บนระบบปฏิบัติการ Android โดยตรง พร้อมระบบสัมผัส (Touch UI) ที่ทันสมัย และระบบอัปเดตอัตโนมัติในตัว (In-App Auto-Updater)
|
||||
**ppSR & Escape Penacony** เป็นแอปพลิเคชัน Android แบบ All-in-One ที่รวมทั้ง **ตัวรันเซิร์ฟเวอร์จำลอง ppSR (Embedded Python 3.14)**, **ตัวอัปเดตเซิร์ฟเวอร์ตรงจากต้นทาง AFN**, **ระบบวาร์ปแผนที่ 86 ฉาก**, และ **เครื่องมือปรับแต่งเซิร์ฟเวอร์** ไว้ในแอปเดียวจบ **โดยไม่ต้องใช้สิทธิ์ 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.2.1`
|
||||
- **สถาปัตยกรรม (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`
|
||||
- **📦 Pre-bundled Python Dependencies (100% Offline):** บรรจุแพ็กเกจ Python ทั้ง 19 ตัว (`requests`, `werkzeug`, `flask`, `marshmallow`, `dataclasses-json`, `protobuf`, `urllib3`, `certifi`, `idna`, `charset-normalizer`, `jinja2`, `click`, `blinker`, ฯลฯ) ฝังไว้ในแอปตั้งแต่ติดตั้ง หมดปัญหา `No module named 'requests'` หรือ `werkzeug` โดยไม่ต้องต่อเน็ตดาวน์โหลด wheel จาก PyPI
|
||||
- **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 ได้ทันที
|
||||
- **⭐ EX Eidolon (3 EX Rank) Manager:** เปิด/ปิดการทำงานของระบบ 3 EX Eidolon ปลดล็อกสกิลพิเศษสำหรับตัวละครยอดฮิต (JingYuan [1204], DanHeng IL [1213], Acheron [1308]) ได้สะดวกจากหน้าจอมือถือ พร้อมปุ่ม Enable/Disable All
|
||||
- **⚡ 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. 🔄 ระบบอัปเดตคู่ (Dual Update System)
|
||||
- **📱 App Updater (อัปเดตตัวแอป APK):**
|
||||
- ตรวจสอบเวอร์ชันล่าสุดของแอปผ่าน GitHub Releases อัตโนมัติในพื้นหลังเมื่อเปิดแอป
|
||||
- มีปุ่ม **"Check App Update"** พร้อมปุ่มเลือก **Channel Selector** สลับระหว่าง:
|
||||
- 🟢 **Latest (Stable):** รับเวอร์ชันเสถียร
|
||||
- 🟡 **Pre-release (Beta):** รับเวอร์ชันทดสอบล่วงหน้า
|
||||
- ดาวน์โหลดและติดตั้งไฟล์ APK อัปเดตได้ทันทีจากในแอป
|
||||
- **⚙️ ppSR Server Core Updater (อัปเดตไฟล์เซิร์ฟเวอร์):**
|
||||
- ซิงค์ตรงกับ File Garden (`VERSIONS` & `build.zip`)
|
||||
- ตรวจสอบเวอร์ชันที่ติดตั้งเทียบกับเวอร์ชันล่าสุดอย่างแม่นยำ (แสดงสถานะ "You are using the latest version" เมื่อเวอร์ชันตรงกัน)
|
||||
- ระบบติดตั้งแบบ Staging ป้องกัน Zip Slip และสำรองไฟล์คอนฟิกผู้ใช้ (`persistent.json`, `config.json`) โดยใช้วิธี **Smart Schema Merge** ไม่เขียนทับบล็อกข้อมูลตัวละคร (`avatar`) ข้อมูลไม่หายและไม่เกิดบั๊ก
|
||||
- เมนู **Select Version Archive** เลือกติดตั้งเวอร์ชันเก่าหรือใหม่ย้อนหลังได้อิสระ
|
||||
|
||||
### 4. 🗺️ ตัวเลือกโลกและวาร์ปแผนที่ (Escape Penacony Teleporter)
|
||||
- ครอบคลุม **8 ดวงดาว 86 ฉาก/แผนที่** จาก `scenes.json` (Astral Express, Space Station, Jarilo-VI, Xianzhou Luofu, Penacony, Amphoreus / Planarcadia ฯลฯ)
|
||||
- **รองรับแมพ Beta ล่าสุด:** เพิ่มฉาก **Moment of Joy (Planarcadia Beta)** [Plane ID: 20572] พร้อมระบบ Scene Cache Auto-Bust
|
||||
- **ระบบค้นหาและฟิลเตอร์:** ค้นหาชื่อฉากหรือ 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)** ได้ทันที
|
||||
|
||||
### 5. 🛡️ ระบบป้องกันการแครชและซ่อมแซมตัวเอง (Self-Healing & Crash Guard)
|
||||
- **SafeDict Runtime:** ห่อหุ้ม `Manager.persistent` ด้วย `SafeDict` คืนค่า Fallback Schema มาตรฐานทันทีหากตรวจพบคีย์ขาดหาย ป้องกัน `KeyError: 'avatar'` (Error 1001_1 / 1001_3) ไม่ให้เซิร์ฟเวอร์หลุด
|
||||
- **Router Packet Isolation:** ดักจับ Unhandled Exception ใน Handler เพื่อไม่ให้คำขอเดี่ยวของเกมล่ม Thread `read_loop`
|
||||
- **Persistent Auto-Healing:** ซ่อมแซมและตรวจสอบความสมบูรณ์ของ `persistent.json` อัตโนมัติทันทีที่เปิดแอป และก่อนเริ่มเซิร์ฟเวอร์
|
||||
- **Werkzeug Android Patch:** แก้ปัญหา `importlib.metadata.PackageNotFoundError` บน Android WSGI Dispatch Server
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ โครงสร้างโปรเจกต์ (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.2.1.apk`
|
||||
|
||||
---
|
||||
|
||||
## 📜 บันทึกการเปลี่ยนแปลง (Changelog)
|
||||
|
||||
### v1.2.1 (Build 9)
|
||||
- **App Updater Crash Fix:** แก้ไขปัญหาแอปปิดตัวลงกะทันหัน (Force Close/Crash) ขณะกดอัปเดตแอป:
|
||||
- เพิ่มสิทธิ์ `REQUEST_INSTALL_PACKAGES` ใน `AndroidManifest.xml` ให้สอดคล้องกับมาตรการความปลอดภัยบน Android 8.0 - 15
|
||||
- ขยายการกำหนดค่า `FileProvider` (`file_paths.xml`) รองรับ Storage Path ทั้ง internal files, cache, external storage ครอบคลุมมือถือทุกรุ่น
|
||||
- ปรับปรุง `AppUpdateManager` ให้รองรับ HTTP Redirects แบบ Multi-hop (เช่น GitHub Assets CDN) และตรวจสอบความสมบูรณ์ของไฟล์ APK ก่อนติดตั้ง
|
||||
- ครอบ Intent Launch ด้วย Error Handling แบบปลอดภัย พร้อมแจ้งเตือนผู้ใช้หากยังไม่ได้เปิดสิทธิ์ Unknown Sources แทนการหยุดทำงานของแอป
|
||||
- **Scrollable Changelog:** ปรับกล่องข้อความบันทึกการเปลี่ยนแปลงในหน้าต่างอัปเดตให้สามารถเลื่อนอ่าน (Vertical Scroll) ได้อย่างอิสระ ไม่ถูกตัดบรรทัด
|
||||
- **Release Title Display:** แสดงหัวข้อการอัปเดต (Release Title) ชัดเจนทั้งในหน้าต่างแจ้งเตือนและแผง Updater Tab
|
||||
|
||||
### v1.2.0 (Build 8)
|
||||
- **New Feature - EX Eidolon (3 EX Rank) Manager:** เพิ่ม UI หน้าต่างปรับแต่งและเปิด/ปิดระบบ 3 EX Eidolon สำหรับตัวละครพิเศษ:
|
||||
- JingYuan (จิ่งหยวน) [Avatar ID: 1204]
|
||||
- DanHeng IL (จ้าวยลจันทรา) [Avatar ID: 1213]
|
||||
- Acheron (อาเคอรอน) [Avatar ID: 1308]
|
||||
- **SafeDict & Auto-Heal Protection:** อัปเดตโครงสร้าง Default Persistent และ SafeDict ใน Python Runtime รองรับคีย์ `ex_rank` ครบถ้วน หมดกังวลเรื่อง KeyError
|
||||
- **UI Quick Tools Refactor:** ปรับปรุงแผงควบคุม Server Tools เป็นรูปแบบ 2x2 Grid (Password, Trailblazer, EX Eidolon, Misc Files)
|
||||
- **Smart Update Persistence:** ปรับระบบผสานข้อมูลตอนอัปเดตเซิร์ฟเวอร์ ให้คงการตั้งค่า `ex_rank` ของผู้ใช้ไว้อย่างปลอดภัย
|
||||
|
||||
### v1.1.4 (Build 7)
|
||||
- **Critical Fix:** แก้ปัญหา Error 1001_1 และ 1001_3 (`KeyError: 'avatar'`) ใน `GetBasicInfoCsReq` ที่ทำให้ Thread `read_loop` เซิร์ฟเวอร์ปิดตัวลง
|
||||
- **SafeDict Runtime:** ห่อหุ้ม `Manager.persistent` ด้วย `SafeDict` คืนค่า Fallback Schema อัตโนมัติ ป้องกัน `KeyError` สำหรับทุกคีย์ข้อมูลตัวละคร
|
||||
- **Router Crash Guard:** ดักจับ Unhandled Exceptions ใน Packet Handler ไม่ให้เซิร์ฟเวอร์หลักล่ม
|
||||
- **Auto-Heal Persistent Storage:** ตรวจสอบและซ่อมแซมไฟล์ `persistent.json` อัตโนมัติเมื่อเปิดแอปและก่อนสตาร์ทเซิร์ฟเวอร์
|
||||
- **Smart Update Migration:** ผสานคอนฟิกเดิมของผู้ใช้เข้ากับแม่แบบโครงสร้างที่สมบูรณ์ ป้องกันข้อมูลตัวละครสูญหายตอนอัปเดตเซิร์ฟเวอร์
|
||||
- **New Beta Scene:** เพิ่มฉาก "Moment of Joy (Planarcadia Beta)" [Plane ID: 20572] พร้อมระบบ Auto Cache-Bust
|
||||
- **App Update Channel Selector:** เพิ่มตัวเลือกรับอัปเดตแอปช่องทาง Latest (Stable) หรือ Pre-release (Beta)
|
||||
|
||||
---
|
||||
|
||||
## 📄 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 = 9
|
||||
versionName = "v1.2.1"
|
||||
|
||||
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,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name="com.chaquo.python.android.PyApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -23,6 +28,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 +48,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": "8e998f6e20dc7152ab075f8edf0644d411cd7f76",
|
||||
"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.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "4.6.5X-MASTER-LOCALIZED",
|
||||
"updated_at": "2026-10-04",
|
||||
"version": "4.7.0-BETA-UPDATE",
|
||||
"updated_at": "2026-10-13",
|
||||
"total_worlds": 8,
|
||||
"total_scenes": 85,
|
||||
"total_combat_scenes": 16,
|
||||
@@ -2096,17 +2096,17 @@
|
||||
"beta_notice_zh": "需要 4.7 Beta 测试端,正式服/Product 客户端会卡在列车加载界面"
|
||||
},
|
||||
{
|
||||
"name": "Fleeting Joy",
|
||||
"name_th": "Fleeting Joy (บาร์ระคนสุขโศก)",
|
||||
"name": "Moment of Joy",
|
||||
"name_th": "Moment of Joy (Fleeting Joy / บาร์ระคนสุขโศก)",
|
||||
"name_zh": "瞬息欢愉",
|
||||
"plane_id": 20572,
|
||||
"x": -743,
|
||||
"y": 2435,
|
||||
"z": -64637,
|
||||
"map_layer": 1,
|
||||
"desc": "A bar where sorrow and joy are mixed",
|
||||
"desc_th": "บาร์สังสรรค์ที่ความโศกเศร้าและความสุขผสมผสานกัน",
|
||||
"desc_zh": "悲欢交织的瞬息酒吧",
|
||||
"desc": "A bar where sorrow and joy are mixed (Official EN: Moment of Joy, formerly Fleeting Joy)",
|
||||
"desc_th": "บาร์สังสรรค์ที่ความโศกเศร้าและความสุขผสมผสานกัน (ชื่อทางการ EN: Moment of Joy / เดิมเรียก Fleeting Joy)",
|
||||
"desc_zh": "悲欢交织的瞬息酒吧(官方英文:Moment of Joy,此前曾称 Fleeting Joy)",
|
||||
"calyx_x": -743,
|
||||
"calyx_y": -999999,
|
||||
"calyx_z": -64637,
|
||||
|
||||
@@ -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,284 @@
|
||||
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.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class AppReleaseInfo(
|
||||
val tagName: String,
|
||||
val releaseTitle: String,
|
||||
val changelog: String,
|
||||
val apkDownloadUrl: String,
|
||||
val isNewer: Boolean,
|
||||
val isPrerelease: Boolean = false
|
||||
)
|
||||
|
||||
object AppUpdateManager {
|
||||
|
||||
private val GITHUB_REPOS = listOf(
|
||||
"CAtlas0784/Escape-penacony-standalone",
|
||||
"CAtlas0784/Escape-penacony"
|
||||
)
|
||||
|
||||
private const val PREFS_NAME = "app_updater_prefs"
|
||||
private const val KEY_INCLUDE_PRERELEASE = "include_prerelease"
|
||||
|
||||
fun isIncludePrerelease(context: Context): Boolean {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_INCLUDE_PRERELEASE, true)
|
||||
}
|
||||
|
||||
fun setIncludePrerelease(context: Context, value: Boolean) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_INCLUDE_PRERELEASE, value)
|
||||
.apply()
|
||||
}
|
||||
|
||||
suspend fun checkForAppUpdate(
|
||||
includePrerelease: Boolean,
|
||||
currentVersion: String = BuildConfig.VERSION_NAME
|
||||
): Result<AppReleaseInfo?> = withContext(Dispatchers.IO) {
|
||||
for (repo in GITHUB_REPOS) {
|
||||
try {
|
||||
val url = URL("https://api.github.com/repos/$repo/releases")
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
setRequestProperty("Accept", "application/vnd.github.v3+json")
|
||||
setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
}
|
||||
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val array = JSONArray(body)
|
||||
if (array.length() == 0) continue
|
||||
|
||||
var chosenRelease: JSONObject? = null
|
||||
for (i in 0 until array.length()) {
|
||||
val rel = array.getJSONObject(i)
|
||||
if (rel.optBoolean("draft", false)) continue
|
||||
|
||||
val isPre = rel.optBoolean("prerelease", false)
|
||||
if (includePrerelease) {
|
||||
chosenRelease = rel
|
||||
break
|
||||
} else {
|
||||
if (!isPre) {
|
||||
chosenRelease = rel
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenRelease != null) {
|
||||
val tagName = chosenRelease.getString("tag_name")
|
||||
val title = chosenRelease.optString("name", tagName)
|
||||
val changelog = chosenRelease.optString("body", "")
|
||||
val isPre = chosenRelease.optBoolean("prerelease", false)
|
||||
|
||||
var downloadUrl = ""
|
||||
val assets = chosenRelease.optJSONArray("assets")
|
||||
if (assets != null) {
|
||||
for (j in 0 until assets.length()) {
|
||||
val asset = assets.getJSONObject(j)
|
||||
val name = asset.optString("name", "")
|
||||
if (name.endsWith(".apk", ignoreCase = true)) {
|
||||
downloadUrl = asset.getString("browser_download_url")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isNewer = isNewerVersion(currentVersion, tagName)
|
||||
return@withContext Result.success(
|
||||
AppReleaseInfo(
|
||||
tagName = tagName,
|
||||
releaseTitle = title,
|
||||
changelog = changelog,
|
||||
apkDownloadUrl = downloadUrl,
|
||||
isNewer = isNewer,
|
||||
isPrerelease = isPre
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Try next repo
|
||||
}
|
||||
}
|
||||
Result.success(null)
|
||||
}
|
||||
|
||||
suspend fun downloadApk(
|
||||
context: Context,
|
||||
downloadUrl: String,
|
||||
onProgress: (Int) -> Unit
|
||||
): Result<File> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
var currentUrl = downloadUrl
|
||||
var conn: HttpURLConnection? = null
|
||||
var redirects = 0
|
||||
val maxRedirects = 6
|
||||
|
||||
while (redirects < maxRedirects) {
|
||||
val u = URL(currentUrl)
|
||||
val c = (u.openConnection() as HttpURLConnection).apply {
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 15000
|
||||
readTimeout = 25000
|
||||
setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
|
||||
}
|
||||
|
||||
val code = c.responseCode
|
||||
if (code == HttpURLConnection.HTTP_MOVED_PERM ||
|
||||
code == HttpURLConnection.HTTP_MOVED_TEMP ||
|
||||
code == HttpURLConnection.HTTP_SEE_OTHER ||
|
||||
code == 307 || code == 308
|
||||
) {
|
||||
val location = c.getHeaderField("Location")
|
||||
?: throw java.io.IOException("Redirect with no Location header")
|
||||
c.disconnect()
|
||||
currentUrl = if (location.startsWith("http://") || location.startsWith("https://")) {
|
||||
location
|
||||
} else {
|
||||
URL(u, location).toString()
|
||||
}
|
||||
redirects++
|
||||
} else if (code in 200..299) {
|
||||
conn = c
|
||||
break
|
||||
} else {
|
||||
c.disconnect()
|
||||
throw java.io.IOException("HTTP error $code: ${c.responseMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
val finalConn = conn ?: throw java.io.IOException("Too many redirects: $redirects")
|
||||
val fileLength = finalConn.contentLength
|
||||
val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val apkFile = File(updateDir, "Escape-penacony-update.apk")
|
||||
if (apkFile.exists()) apkFile.delete()
|
||||
|
||||
finalConn.inputStream.use { input ->
|
||||
FileOutputStream(apkFile).use { output ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var total: Long = 0
|
||||
var count: Int
|
||||
var lastProgress = -1
|
||||
while (input.read(buffer).also { count = it } != -1) {
|
||||
total += count
|
||||
if (fileLength > 0) {
|
||||
val progress = ((total * 100) / fileLength).toInt().coerceIn(0, 100)
|
||||
if (progress != lastProgress) {
|
||||
lastProgress = progress
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!apkFile.exists() || apkFile.length() == 0L) {
|
||||
throw java.io.IOException("Downloaded APK file is empty")
|
||||
}
|
||||
|
||||
Result.success(apkFile)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun promptInstallApk(context: Context, apkFile: File): Result<Unit> {
|
||||
return try {
|
||||
if (!apkFile.exists() || apkFile.length() == 0L) {
|
||||
return Result.failure(IllegalStateException("Downloaded APK file is empty or missing"))
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
try {
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
val fallbackIntent = Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(fallbackIntent)
|
||||
}
|
||||
return Result.failure(SecurityException("Please allow installing unknown apps from this source in Settings, then click Update again."))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun isNewerVersion(current: String, latest: String): Boolean {
|
||||
if (current.equals(latest, ignoreCase = true)) return false
|
||||
|
||||
fun parseParts(ver: String): Pair<List<Int>, String> {
|
||||
val clean = ver.trim().removePrefix("v").removePrefix("V")
|
||||
val parts = clean.split("-", limit = 2)
|
||||
val numbers = parts[0].split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val suffix = if (parts.size > 1) parts[1].lowercase() else ""
|
||||
return Pair(numbers, suffix)
|
||||
}
|
||||
|
||||
val (cNums, cSuffix) = parseParts(current)
|
||||
val (lNums, lSuffix) = parseParts(latest)
|
||||
|
||||
val maxLen = maxOf(cNums.size, lNums.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val cv = cNums.getOrElse(i) { 0 }
|
||||
val lv = lNums.getOrElse(i) { 0 }
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
|
||||
if (cSuffix.isNotBlank() && lSuffix.isBlank()) {
|
||||
return true
|
||||
}
|
||||
if (cSuffix.isBlank() && lSuffix.isNotBlank()) {
|
||||
return false
|
||||
}
|
||||
if (cSuffix.isNotBlank() && lSuffix.isNotBlank()) {
|
||||
return lSuffix > cSuffix
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
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 ExRankConfig(
|
||||
val name: String,
|
||||
val avatarId: Int,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_PERSISTENT_JSON_STRING = """
|
||||
{
|
||||
"send_hotfix": true,
|
||||
"scene": {
|
||||
"plane_id": 20313,
|
||||
"x": 40748,
|
||||
"y": 192819,
|
||||
"z": 439218,
|
||||
"calyx_x": 40748,
|
||||
"calyx_y": 192810,
|
||||
"calyx_z": 441000,
|
||||
"map_layer": 2,
|
||||
"calyx_group_id": 186,
|
||||
"calyx_inst_id": 300001,
|
||||
"calyx_entity_id": 1337,
|
||||
"calyx_prop_id": 808
|
||||
},
|
||||
"avatar": {
|
||||
"tb_gender": "Female",
|
||||
"tb_path": "Elation",
|
||||
"march_path": "Preservation",
|
||||
"skin": {
|
||||
"1001": 1100101,
|
||||
"1310": 1131001,
|
||||
"1415": 1141501,
|
||||
"1407": 1140701,
|
||||
"1501": 1150101
|
||||
},
|
||||
"lineup": [
|
||||
1511,
|
||||
1502,
|
||||
1506,
|
||||
1503
|
||||
],
|
||||
"global_passive": [
|
||||
{
|
||||
"name": "Castorice Global Passive",
|
||||
"id": 140703,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "SW999 Global Passsive",
|
||||
"id": 150602,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "Aha Global Passive",
|
||||
"id": 151104,
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"ex_rank": [
|
||||
{
|
||||
"name": "JingYuan",
|
||||
"avatar_id": 1204,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "DanHeng IL",
|
||||
"avatar_id": 1213,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "Acheron",
|
||||
"avatar_id": 1308,
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"buff": [
|
||||
1000121
|
||||
],
|
||||
"leader": 0
|
||||
},
|
||||
"key": ""
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val DEFAULT_EX_RANK_CONFIGS = listOf(
|
||||
ExRankConfig("JingYuan", 1204, true),
|
||||
ExRankConfig("DanHeng IL", 1213, true),
|
||||
ExRankConfig("Acheron", 1308, true)
|
||||
)
|
||||
}
|
||||
|
||||
fun createDefaultPersistentJson(): JSONObject {
|
||||
return JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
|
||||
}
|
||||
|
||||
fun ensureValidPersistentJsonSync(): JSONObject {
|
||||
try {
|
||||
miscDir.mkdirs()
|
||||
val defaultTemplate = createDefaultPersistentJson()
|
||||
if (!persistentFile.exists()) {
|
||||
persistentFile.writeText(defaultTemplate.toString(4), StandardCharsets.UTF_8)
|
||||
return defaultTemplate
|
||||
}
|
||||
val content = persistentFile.readText(StandardCharsets.UTF_8)
|
||||
val json = JSONObject(content)
|
||||
var modified = false
|
||||
|
||||
if (!json.has("send_hotfix")) {
|
||||
json.put("send_hotfix", true)
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("key")) {
|
||||
json.put("key", "")
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("scene") || json.optJSONObject("scene") == null) {
|
||||
json.put("scene", defaultTemplate.getJSONObject("scene"))
|
||||
modified = true
|
||||
}
|
||||
val avatar = json.optJSONObject("avatar")
|
||||
if (avatar == null) {
|
||||
json.put("avatar", defaultTemplate.getJSONObject("avatar"))
|
||||
modified = true
|
||||
} else {
|
||||
val defaultAvatar = defaultTemplate.getJSONObject("avatar")
|
||||
val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "ex_rank", "buff", "leader")
|
||||
for (k in keys) {
|
||||
if (!avatar.has(k) || avatar.isNull(k)) {
|
||||
avatar.put(k, defaultAvatar.get(k))
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
persistentFile.writeText(json.toString(4), StandardCharsets.UTF_8)
|
||||
}
|
||||
return json
|
||||
} catch (e: Exception) {
|
||||
val fallback = createDefaultPersistentJson()
|
||||
try {
|
||||
persistentFile.writeText(fallback.toString(4), StandardCharsets.UTF_8)
|
||||
} catch (_: Exception) {}
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readPersistentJson(): Result<JSONObject> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val json = ensureValidPersistentJsonSync()
|
||||
Result.success(json)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writePersistentJson(json: JSONObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
miscDir.mkdirs()
|
||||
val defaultTemplate = createDefaultPersistentJson()
|
||||
if (!json.has("send_hotfix")) json.put("send_hotfix", true)
|
||||
if (!json.has("key")) json.put("key", "")
|
||||
if (!json.has("scene") || json.optJSONObject("scene") == null) {
|
||||
json.put("scene", defaultTemplate.getJSONObject("scene"))
|
||||
}
|
||||
val avatar = json.optJSONObject("avatar")
|
||||
if (avatar == null) {
|
||||
json.put("avatar", defaultTemplate.getJSONObject("avatar"))
|
||||
} else {
|
||||
val defaultAvatar = defaultTemplate.getJSONObject("avatar")
|
||||
val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "ex_rank", "buff", "leader")
|
||||
for (k in keys) {
|
||||
if (!avatar.has(k) || avatar.isNull(k)) {
|
||||
avatar.put(k, defaultAvatar.get(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { createDefaultPersistentJson() }
|
||||
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 { createDefaultPersistentJson() }
|
||||
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", "Elation") ?: "Elation",
|
||||
marchPath = avatar?.optString("march_path", "Preservation") ?: "Preservation"
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTrailblazerConfig(config: TrailblazerConfig): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
|
||||
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 getExRankConfigs(): List<ExRankConfig> {
|
||||
val json = readPersistentJson().getOrNull() ?: ensureValidPersistentJsonSync()
|
||||
val list = mutableListOf<ExRankConfig>()
|
||||
val avatar = json.optJSONObject("avatar")
|
||||
val array = avatar?.optJSONArray("ex_rank") ?: json.optJSONArray("ex_rank")
|
||||
if (array != null) {
|
||||
for (i in 0 until array.length()) {
|
||||
val item = array.optJSONObject(i) ?: continue
|
||||
list.add(
|
||||
ExRankConfig(
|
||||
name = item.optString("name", "Unknown"),
|
||||
avatarId = item.optInt("avatar_id", 0),
|
||||
active = item.optBoolean("active", true)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return if (list.isNotEmpty()) list else DEFAULT_EX_RANK_CONFIGS
|
||||
}
|
||||
|
||||
suspend fun setExRankConfigs(configs: List<ExRankConfig>): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
|
||||
val avatar = json.optJSONObject("avatar") ?: JSONObject().also { json.put("avatar", it) }
|
||||
val array = org.json.JSONArray()
|
||||
for (cfg in configs) {
|
||||
val item = JSONObject()
|
||||
item.put("name", cfg.name)
|
||||
item.put("avatar_id", cfg.avatarId)
|
||||
item.put("active", cfg.active)
|
||||
array.put(item)
|
||||
}
|
||||
avatar.put("ex_rank", array)
|
||||
return writePersistentJson(json)
|
||||
}
|
||||
|
||||
suspend fun applySceneTeleport(payload: SceneTeleportPayload): Result<Unit> {
|
||||
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,10 @@ object SceneRepository {
|
||||
val defaultObj = JSONObject(defaultJson)
|
||||
val defaultBeta = defaultObj.optInt("total_beta_scenes", 0)
|
||||
val cachedBeta = cachedObj.optInt("total_beta_scenes", 0)
|
||||
// Discard stale cache if bundled asset has more beta scene definitions
|
||||
if (defaultBeta > cachedBeta) {
|
||||
val defaultVer = defaultObj.optString("version", "")
|
||||
val cachedVer = cachedObj.optString("version", "")
|
||||
// Discard stale cache if bundled asset has newer version or more beta scene definitions
|
||||
if (defaultBeta > cachedBeta || (defaultVer.isNotBlank() && defaultVer != cachedVer)) {
|
||||
cachedFile.delete()
|
||||
defaultJson
|
||||
} else {
|
||||
|
||||
@@ -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,313 @@
|
||||
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
|
||||
import org.json.JSONObject
|
||||
|
||||
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)
|
||||
|
||||
// The zip extracts to stagingDir/build/...
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
|
||||
onProgress(0.80f, "Installing server runtime dependencies...")
|
||||
try {
|
||||
extractAssetZip("packages.zip", effectiveStagedDir)
|
||||
} catch (e: Exception) {
|
||||
// Non-fatal if asset missing, will attempt on server start
|
||||
}
|
||||
|
||||
onProgress(0.85f, "Restoring user data...")
|
||||
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
|
||||
|
||||
if (miscBackupFile.exists()) {
|
||||
try {
|
||||
val defaultTemplate = JSONObject(PersistentRepository.DEFAULT_PERSISTENT_JSON_STRING)
|
||||
val backedUpText = miscBackupFile.readText()
|
||||
val backedUpJson = JSONObject(backedUpText)
|
||||
if (backedUpJson.has("send_hotfix")) defaultTemplate.put("send_hotfix", backedUpJson.getBoolean("send_hotfix"))
|
||||
if (backedUpJson.has("key")) defaultTemplate.put("key", backedUpJson.getString("key"))
|
||||
if (backedUpJson.has("scene") && backedUpJson.optJSONObject("scene") != null) {
|
||||
defaultTemplate.put("scene", backedUpJson.getJSONObject("scene"))
|
||||
}
|
||||
val backedUpAvatar = backedUpJson.optJSONObject("avatar")
|
||||
if (backedUpAvatar != null) {
|
||||
val avatar = defaultTemplate.getJSONObject("avatar")
|
||||
val keys = backedUpAvatar.keys()
|
||||
while (keys.hasNext()) {
|
||||
val k = keys.next()
|
||||
avatar.put(k, backedUpAvatar.get(k))
|
||||
}
|
||||
}
|
||||
File(stagedMiscDir, "persistent.json").writeText(defaultTemplate.toString(4))
|
||||
} catch (e: Exception) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun extractAssetZip(assetName: String, outputDir: File) {
|
||||
context.assets.open(assetName).use { inputStream ->
|
||||
ZipInputStream(BufferedInputStream(inputStream)).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
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 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,309 @@
|
||||
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
|
||||
}
|
||||
|
||||
val serverDir = ppsrFile.parentFile ?: File(filesDir, "server/build/build")
|
||||
val requestsPkg = File(serverDir, "requests")
|
||||
val werkzeugPkg = File(serverDir, "werkzeug")
|
||||
if (!requestsPkg.exists() || !werkzeugPkg.exists()) {
|
||||
appendLog("[System] Initializing server runtime dependencies...")
|
||||
try {
|
||||
val updateRepo = com.catlas.escapepenacony.data.UpdateRepository(this)
|
||||
updateRepo.extractAssetZip("packages.zip", serverDir)
|
||||
appendLog("[System] Runtime dependencies initialized successfully.")
|
||||
} catch (e: Exception) {
|
||||
appendLog("[Warning] Dependency initialization notice: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val persistentRepo = com.catlas.escapepenacony.data.PersistentRepository(this)
|
||||
persistentRepo.ensureValidPersistentJsonSync()
|
||||
} catch (e: Exception) {
|
||||
appendLog("[Warning] Persistent validation notice: ${e.message}")
|
||||
}
|
||||
|
||||
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,490 @@
|
||||
import sys
|
||||
import os
|
||||
import builtins
|
||||
import time
|
||||
import _thread
|
||||
import gc
|
||||
import socket
|
||||
import struct
|
||||
import warnings
|
||||
|
||||
# === GLOBAL WARNING SUPPRESSION & METADATA SHIM (very early) ===
|
||||
try:
|
||||
import importlib.metadata
|
||||
_orig_metadata_version = importlib.metadata.version
|
||||
_orig_metadata_distribution = importlib.metadata.distribution
|
||||
|
||||
_KNOWN_VERSIONS = {
|
||||
"werkzeug": "3.1.9",
|
||||
"flask": "3.1.3",
|
||||
"marshmallow": "3.20.0",
|
||||
"requests": "2.34.2",
|
||||
"urllib3": "2.8.0",
|
||||
"protobuf": "7.36.2",
|
||||
"click": "8.5.0",
|
||||
"jinja2": "3.1.6",
|
||||
"itsdangerous": "2.2.0",
|
||||
"blinker": "1.9.0",
|
||||
"markupsafe": "3.0.4",
|
||||
"dataclasses-json": "0.6.7",
|
||||
"dataclasses_json": "0.6.7",
|
||||
"packaging": "26.3"
|
||||
}
|
||||
|
||||
class _ShimedDistribution:
|
||||
def __init__(self, name, version):
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.metadata = {"Name": name, "Version": version}
|
||||
|
||||
def _safe_metadata_version(name):
|
||||
try:
|
||||
return _orig_metadata_version(name)
|
||||
except Exception:
|
||||
clean = name.lower().replace("-", "_")
|
||||
return _KNOWN_VERSIONS.get(name.lower(), _KNOWN_VERSIONS.get(clean, "1.0.0"))
|
||||
|
||||
def _safe_metadata_distribution(name):
|
||||
try:
|
||||
return _orig_metadata_distribution(name)
|
||||
except Exception:
|
||||
ver = _safe_metadata_version(name)
|
||||
return _ShimedDistribution(name, ver)
|
||||
|
||||
importlib.metadata.version = _safe_metadata_version
|
||||
importlib.metadata.distribution = _safe_metadata_distribution
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||
|
||||
try:
|
||||
from marshmallow.warnings import RemovedInMarshmallow4Warning
|
||||
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||
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 = ""
|
||||
self._in_write = False
|
||||
|
||||
def write(self, message):
|
||||
if self._in_write:
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.write(message)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._in_write = True
|
||||
try:
|
||||
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
|
||||
finally:
|
||||
self._in_write = False
|
||||
|
||||
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")
|
||||
|
||||
# --- 3.1 Persistent Template & SafeDict Protection ---
|
||||
DEFAULT_PERSISTENT = {
|
||||
"send_hotfix": True,
|
||||
"scene": {
|
||||
"plane_id": 20313,
|
||||
"x": 40748,
|
||||
"y": 192819,
|
||||
"z": 439218,
|
||||
"calyx_x": 40748,
|
||||
"calyx_y": 192810,
|
||||
"calyx_z": 441000,
|
||||
"map_layer": 2,
|
||||
"calyx_group_id": 186,
|
||||
"calyx_inst_id": 300001,
|
||||
"calyx_entity_id": 1337,
|
||||
"calyx_prop_id": 808
|
||||
},
|
||||
"avatar": {
|
||||
"tb_gender": "Female",
|
||||
"tb_path": "Elation",
|
||||
"march_path": "Preservation",
|
||||
"skin": {
|
||||
"1001": 1100101,
|
||||
"1310": 1131001,
|
||||
"1415": 1141501,
|
||||
"1407": 1140701,
|
||||
"1501": 1150101
|
||||
},
|
||||
"lineup": [
|
||||
1511,
|
||||
1502,
|
||||
1506,
|
||||
1503
|
||||
],
|
||||
"global_passive": [
|
||||
{
|
||||
"name": "Castorice Global Passive",
|
||||
"id": 140703,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "SW999 Global Passsive",
|
||||
"id": 150602,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "Aha Global Passive",
|
||||
"id": 151104,
|
||||
"active": True
|
||||
}
|
||||
],
|
||||
"ex_rank": [
|
||||
{
|
||||
"name": "JingYuan",
|
||||
"avatar_id": 1204,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "DanHeng IL",
|
||||
"avatar_id": 1213,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "Acheron",
|
||||
"avatar_id": 1308,
|
||||
"active": True
|
||||
}
|
||||
],
|
||||
"buff": [
|
||||
1000121
|
||||
],
|
||||
"leader": 0
|
||||
},
|
||||
"key": ""
|
||||
}
|
||||
|
||||
class SafeDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, v in list(self.items()):
|
||||
if isinstance(v, dict) and not isinstance(v, SafeDict):
|
||||
self[k] = SafeDict(v)
|
||||
|
||||
def __getitem__(self, key):
|
||||
import copy
|
||||
if key not in self:
|
||||
if key == 'avatar':
|
||||
self['avatar'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']))
|
||||
return self['avatar']
|
||||
elif key == 'scene':
|
||||
self['scene'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['scene']))
|
||||
return self['scene']
|
||||
elif key == 'skin':
|
||||
self['skin'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']['skin']))
|
||||
return self['skin']
|
||||
elif key == 'lineup':
|
||||
self['lineup'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['lineup'])
|
||||
return self['lineup']
|
||||
elif key == 'buff':
|
||||
self['buff'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['buff'])
|
||||
return self['buff']
|
||||
elif key == 'global_passive':
|
||||
self['global_passive'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['global_passive'])
|
||||
return self['global_passive']
|
||||
elif key == 'ex_rank':
|
||||
self['ex_rank'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['ex_rank'])
|
||||
return self['ex_rank']
|
||||
elif key == 'tb_gender':
|
||||
return 'Female'
|
||||
elif key == 'tb_path':
|
||||
return 'Elation'
|
||||
elif key == 'march_path':
|
||||
return 'Preservation'
|
||||
elif key == 'leader':
|
||||
return 0
|
||||
elif key == 'send_hotfix':
|
||||
return True
|
||||
elif key == 'key':
|
||||
return ''
|
||||
val = super().__getitem__(key)
|
||||
if isinstance(val, dict) and not isinstance(val, SafeDict):
|
||||
val = SafeDict(val)
|
||||
self[key] = val
|
||||
return val
|
||||
|
||||
def _sanitize_persistent_file(persistent_file):
|
||||
try:
|
||||
import json, os, copy
|
||||
os.makedirs(os.path.dirname(os.path.abspath(persistent_file)), exist_ok=True)
|
||||
data = {}
|
||||
if os.path.exists(persistent_file):
|
||||
try:
|
||||
with open(persistent_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
modified = False
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
modified = True
|
||||
|
||||
for k, v in DEFAULT_PERSISTENT.items():
|
||||
if k not in data:
|
||||
data[k] = copy.deepcopy(v)
|
||||
modified = True
|
||||
elif isinstance(v, dict) and isinstance(data[k], dict):
|
||||
for sub_k, sub_v in v.items():
|
||||
if sub_k not in data[k]:
|
||||
data[k][sub_k] = copy.deepcopy(sub_v)
|
||||
modified = True
|
||||
|
||||
if modified or not os.path.exists(persistent_file):
|
||||
with open(persistent_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
print("[Patch] persistent.json validated and auto-healed with complete avatar/scene defaults")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] sanitize persistent: {e}")
|
||||
|
||||
# --- 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")
|
||||
pkg_zip = os.path.join(ppsr_dir, "packages.zip")
|
||||
|
||||
if os.path.exists(ppsr_dir):
|
||||
os.chdir(ppsr_dir)
|
||||
print(f"Working directory locked: {os.getcwd()}")
|
||||
|
||||
for path in [ppsr_dir, misc_dir, pkg_zip]:
|
||||
if os.path.exists(path) and path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||
|
||||
# Verify and auto-recover critical packages if needed
|
||||
for pkg_name in ["requests", "werkzeug", "flask"]:
|
||||
try:
|
||||
__import__(pkg_name)
|
||||
except ImportError:
|
||||
print(f"[Shim] Package '{pkg_name}' missing, attempting auto-install...")
|
||||
try:
|
||||
import package_fetcher
|
||||
package_fetcher.install_package(pkg_name, ppsr_dir)
|
||||
except Exception as ex:
|
||||
print(f"[Shim Warning] Fallback failed for {pkg_name}: {ex}")
|
||||
|
||||
# --- Werkzeug Patch ---
|
||||
try:
|
||||
import werkzeug.serving
|
||||
if hasattr(werkzeug.serving, "ThreadedWSGIServer"):
|
||||
try:
|
||||
werkzeug.serving.ThreadedWSGIServer._server_version = "Werkzeug/3.1.9"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_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:
|
||||
server._server_version = "Werkzeug/3.1.9"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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}")
|
||||
|
||||
# --- Persistent & Avatar Auto-healing Patch ---
|
||||
_persistent_file = os.path.join(misc_dir, "persistent.json")
|
||||
_sanitize_persistent_file(_persistent_file)
|
||||
|
||||
try:
|
||||
import game.services.manager as _mgr_mod
|
||||
_orig_load_persistent = _mgr_mod.Manager.load_persistent
|
||||
|
||||
@classmethod
|
||||
def _patched_load_persistent(cls):
|
||||
_orig_load_persistent()
|
||||
if hasattr(cls, 'persistent') and isinstance(cls.persistent, dict):
|
||||
cls.persistent = SafeDict(cls.persistent)
|
||||
|
||||
_mgr_mod.Manager.load_persistent = _patched_load_persistent
|
||||
if hasattr(_mgr_mod.Manager, 'persistent') and isinstance(_mgr_mod.Manager.persistent, dict):
|
||||
_mgr_mod.Manager.persistent = SafeDict(_mgr_mod.Manager.persistent)
|
||||
print("[Patch] Manager.load_persistent patched with SafeDict avatar protection")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] Manager persistent patch: {e}")
|
||||
|
||||
try:
|
||||
import game.router as _router_mod
|
||||
_orig_router_handle = _router_mod.handle
|
||||
|
||||
def _safe_router_handle(data):
|
||||
try:
|
||||
return _orig_router_handle(data)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[Router Warning] Handled exception in router.handle: {e}")
|
||||
traceback.print_exc()
|
||||
return b''
|
||||
|
||||
_router_mod.handle = _safe_router_handle
|
||||
print("[Patch] game.router.handle wrapped with crash-guard")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] Router crash-guard: {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}")
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-files-path name="downloads" path="Download/" />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="internal_files" path="." />
|
||||
<cache-path name="internal_cache" path="." />
|
||||
<external-path name="external" path="." />
|
||||
<external-files-path name="external_files" path="." />
|
||||
<external-cache-path name="external_cache" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,194 @@
|
||||
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"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExRankConfigUpdate() {
|
||||
val root = JSONObject("""{"avatar": {}}""")
|
||||
val avatar = root.getJSONObject("avatar")
|
||||
|
||||
val list = listOf(
|
||||
com.catlas.escapepenacony.data.ExRankConfig("JingYuan", 1204, true),
|
||||
com.catlas.escapepenacony.data.ExRankConfig("DanHeng IL", 1213, false),
|
||||
com.catlas.escapepenacony.data.ExRankConfig("Acheron", 1308, true)
|
||||
)
|
||||
|
||||
val array = org.json.JSONArray()
|
||||
for (cfg in list) {
|
||||
val item = JSONObject()
|
||||
item.put("name", cfg.name)
|
||||
item.put("avatar_id", cfg.avatarId)
|
||||
item.put("active", cfg.active)
|
||||
array.put(item)
|
||||
}
|
||||
avatar.put("ex_rank", array)
|
||||
|
||||
val savedArray = avatar.getJSONArray("ex_rank")
|
||||
assertEquals(3, savedArray.length())
|
||||
assertEquals("JingYuan", savedArray.getJSONObject(0).getString("name"))
|
||||
assertEquals(1204, savedArray.getJSONObject(0).getInt("avatar_id"))
|
||||
assertTrue(savedArray.getJSONObject(0).getBoolean("active"))
|
||||
|
||||
assertEquals("DanHeng IL", savedArray.getJSONObject(1).getString("name"))
|
||||
assertFalse(savedArray.getJSONObject(1).getBoolean("active"))
|
||||
|
||||
assertEquals("Acheron", savedArray.getJSONObject(2).getString("name"))
|
||||
assertTrue(savedArray.getJSONObject(2).getBoolean("active"))
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.java.home=C:/Program Files/Java/jdk-22
|
||||
Reference in new issue
Block a user