feat: v1.1.1 - Pre-bundle Python runtime packages, dual update system, and UI localization

This commit is contained in:
CAtlas0784 committed 2026-10-10 15:27:32 +07:00
1 parent e7e639b5a2
commit 2de9a13c89
9 files changed
+855 -122

No files matched your search

+11 -6
View File
@@ -4,7 +4,7 @@
**Escape Penacony Standalone** เป็นแอปพลิเคชัน Android แบบ All-in-One ที่รวมทั้ง **ตัวรันเซิร์ฟเวอร์จำลอง ppSR (Embedded Python 3.14)**, **ตัวอัปเดตเซิร์ฟเวอร์ตรงจากต้นทาง AFN**, **ระบบวาร์ปแผนที่ 85 ฉาก**, และ **เครื่องมือปรับแต่งเซิร์ฟเวอร์** ไว้ในแอปเดียวจบ **โดยไม่ต้องใช้สิทธิ์ Root, ไม่ต้องใช้ Shizuku และไม่ต้องติดตั้งแอป com.afn.ppSR แยกต่างหาก**
- **Application ID / Package**: `com.catlas.escapepenacony`
- **เวอร์ชันล่าสุด (Current Version)**: `v1.1.0`
- **เวอร์ชันล่าสุด (Current Version)**: `v1.1.1`
- **สถาปัตยกรรม (Architecture)**: Native Android (Kotlin + Jetpack Compose + Material Design 3 + Embedded Chaquopy Python 3.14)
- **Target ABI**: `arm64-v8a` (Android 8.0+ / API 26+)
@@ -14,6 +14,7 @@
### 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`
@@ -28,11 +29,15 @@
- *ปิด (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:** สามารถเลือกติดตั้งเวอร์ชันเก่าหรือเวอร์ชันใหม่ย้อนหลังได้อิสระ
### 3. 🔄 ระบบอัปเดตคู่ (Dual Update System)
- **📱 App Updater (อัปเดตตัวแอป APK):**
- ตรวจสอบเวอร์ชันล่าสุดของแอปผ่าน GitHub Releases อัตโนมัติในพื้นหลังเมื่อเปิดแอป
- มีปุ่ม **"Check App Update"** ให้กดตรวจหาและดาวน์โหลดติดตั้ง APK เวอร์ชันใหม่ได้ทันทีจากในแอป
- **⚙️ ppSR Server Core Updater (อัปเดตไฟล์เซิร์ฟเวอร์):**
- ซิงค์ตรงกับ File Garden (`VERSIONS` & `build.zip`)
- ตรวจสอบเวอร์ชันที่ติดตั้งเทียบกับเวอร์ชันล่าสุดอย่างแม่นยำ (แสดงสถานะ "You are using the latest version" เมื่อเวอร์ชันตรงกัน)
- ระบบติดตั้งแบบ Staging ป้องกัน Zip Slip และสำรองไฟล์คอนฟิกผู้ใช้ (`persistent.json`, `config.json`) ให้อัตโนมัติ เซฟไม่หาย 100%
- เมนู **Select Version Archive** เลือกติดตั้งเวอร์ชันเก่าหรือใหม่ย้อนหลังได้อิสระ
### 4. 🗺️ ตัวเลือกโลกและวาร์ปแผนที่ (Escape Penacony Teleporter)
- ครอบคลุม **8 ดวงดาว 85 ฉาก/แผนที่** จาก `scenes.json` (Astral Express, Space Station, Jarilo-VI, Xianzhou Luofu, Penacony ฯลฯ)
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.catlas.escapepenacony"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "v1.1.0"
versionCode = 4
versionName = "v1.1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,163 @@
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 AppReleaseInfo(
val tagName: String,
val releaseTitle: String,
val changelog: String,
val apkDownloadUrl: String,
val isNewer: Boolean
)
object AppUpdateManager {
private val GITHUB_ENDPOINTS = listOf(
"https://api.github.com/repos/CAtlas0784/Escape-penacony-standalone/releases/latest",
"https://api.github.com/repos/CAtlas0784/Escape-penacony/releases/latest"
)
suspend fun checkForAppUpdate(currentVersion: String = BuildConfig.VERSION_NAME): Result<AppReleaseInfo?> =
withContext(Dispatchers.IO) {
for (endpoint in GITHUB_ENDPOINTS) {
try {
val url = URL(endpoint)
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 json = JSONObject(body)
val tagName = json.getString("tag_name")
val title = json.optString("name", tagName)
val changelog = json.optString("body", "")
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)
return@withContext Result.success(
AppReleaseInfo(
tagName = tagName,
releaseTitle = title,
changelog = changelog,
apkDownloadUrl = downloadUrl,
isNewer = isNewer
)
)
}
} catch (e: Exception) {
// Try next endpoint
}
}
Result.success(null)
}
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).apply {
connectTimeout = 15000
readTimeout = 15000
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) {
Result.failure(e)
}
}
fun promptInstallApk(context: Context, apkFile: File) {
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)
}
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
}
}
@@ -172,10 +172,18 @@ class UpdateRepository(private val context: Context) {
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
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()) {
@@ -212,6 +220,35 @@ class UpdateRepository(private val context: Context) {
}
}
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
@@ -147,6 +147,20 @@ class ServerService : Service() {
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}")
}
}
appendLog("[System] Launching ppSR Python 3.14 Server...")
// Start readiness check in background
File diff suppressed because it is too large. Load diff
+38 -12
View File
@@ -27,22 +27,35 @@ class AndroidLogStream:
self.activity_context = activity_context
self.original_stream = original_stream
self.buffer = ""
self._in_write = False
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:
if self._in_write:
if self.original_stream:
try:
self.activity_context.onServerLog(clean_line)
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:
@@ -137,15 +150,28 @@ def run_server(ppsr_path, activity_context=None):
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]:
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)
# 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