feat: v1.1.0 - All-in-One Standalone app with embedded Python 3.14 runtime, AFN direct updater, and server tools

This commit is contained in:
CAtlas0784 committed 2026-10-10 12:26:06 +07:00
1 parent 249c922a3e
commit e7e639b5a2
42 files changed
+7304 -1507

No files matched your search

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