Compare commits

...
10 Commits
49 changed files with 9086 additions and 1498 deletions

No files matched your search

+2
View File
@@ -11,3 +11,5 @@ local.properties
/app/build
*.apk
__pycache__/
*.pyc
+129 -66
View File
@@ -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
View File
@@ -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.
+15 -8
View File
@@ -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.
Binary file not shown.
+23
View File
@@ -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.
+7 -7
View File
@@ -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.
+16
View File
@@ -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
+155
View File
@@ -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
+490
View File
@@ -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}")
+5 -2
View File
@@ -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"))
}
}
+1
View File
@@ -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