feat: v1.1.1 - Pre-bundle Python runtime packages, dual update system, and UI localization
This commit is contained in:
1 parent
e7e639b5a2
commit
2de9a13c89
9 files changed
+855
-122
No files matched your search
@@ -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 ฯลฯ)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user