fix: v1.2.1 - resolve updater crash, add scrollable changelog and release title

This commit is contained in:
CAtlas0784 committed 2026-10-18 02:31:51 +07:00
1 parent 96ec5e950e
commit ab63f506c8
6 files changed
+190 -49

No files matched your search

+11 -2
View File
@@ -4,7 +4,7 @@
**ppSR & Escape Penacony** เป็นแอปพลิเคชัน Android แบบ All-in-One ที่รวมทั้ง **ตัวรันเซิร์ฟเวอร์จำลอง ppSR (Embedded Python 3.14)**, **ตัวอัปเดตเซิร์ฟเวอร์ตรงจากต้นทาง AFN**, **ระบบวาร์ปแผนที่ 86 ฉาก**, และ **เครื่องมือปรับแต่งเซิร์ฟเวอร์** ไว้ในแอปเดียวจบ **โดยไม่ต้องใช้สิทธิ์ Root, ไม่ต้องใช้ Shizuku และไม่ต้องติดตั้งแอป com.afn.ppSR แยกต่างหาก**
- **Application ID / Package**: `com.catlas.escapepenacony`
- **เวอร์ชันล่าสุด (Current Version)**: `v1.2.0`
- **เวอร์ชันล่าสุด (Current Version)**: `v1.2.1`
- **สถาปัตยกรรม (Architecture)**: Native Android (Kotlin + Jetpack Compose + Material Design 3 + Embedded Chaquopy Python 3.14)
- **Target ABI**: `arm64-v8a` (Android 8.0+ / API 26+)
@@ -119,12 +119,21 @@ Escape-penacony-standalone/
ไฟล์ APK ที่บิลด์เสร็จแล้วจะอยู่ที่:
- `app/build/outputs/apk/release/app-release.apk`
- หรือตัวสำเนาที่ root โฟลเดอร์: `Escape-penacony-standalone-v1.2.0.apk`
- หรือตัวสำเนาที่ root โฟลเดอร์: `Escape-penacony-standalone-v1.2.1.apk`
---
## 📜 บันทึกการเปลี่ยนแปลง (Changelog)
### v1.2.1 (Build 9)
- **App Updater Crash Fix:** แก้ไขปัญหาแอปปิดตัวลงกะทันหัน (Force Close/Crash) ขณะกดอัปเดตแอป:
- เพิ่มสิทธิ์ `REQUEST_INSTALL_PACKAGES` ใน `AndroidManifest.xml` ให้สอดคล้องกับมาตรการความปลอดภัยบน Android 8.0 - 15
- ขยายการกำหนดค่า `FileProvider` (`file_paths.xml`) รองรับ Storage Path ทั้ง internal files, cache, external storage ครอบคลุมมือถือทุกรุ่น
- ปรับปรุง `AppUpdateManager` ให้รองรับ HTTP Redirects แบบ Multi-hop (เช่น GitHub Assets CDN) และตรวจสอบความสมบูรณ์ของไฟล์ APK ก่อนติดตั้ง
- ครอบ Intent Launch ด้วย Error Handling แบบปลอดภัย พร้อมแจ้งเตือนผู้ใช้หากยังไม่ได้เปิดสิทธิ์ Unknown Sources แทนการหยุดทำงานของแอป
- **Scrollable Changelog:** ปรับกล่องข้อความบันทึกการเปลี่ยนแปลงในหน้าต่างอัปเดตให้สามารถเลื่อนอ่าน (Vertical Scroll) ได้อย่างอิสระ ไม่ถูกตัดบรรทัด
- **Release Title Display:** แสดงหัวข้อการอัปเดต (Release Title) ชัดเจนทั้งในหน้าต่างแจ้งเตือนและแผง Updater Tab
### v1.2.0 (Build 8)
- **New Feature - EX Eidolon (3 EX Rank) Manager:** เพิ่ม UI หน้าต่างปรับแต่งและเปิด/ปิดระบบ 3 EX Eidolon สำหรับตัวละครพิเศษ:
- JingYuan (จิ่งหยวน) [Avatar ID: 1204]
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.catlas.escapepenacony"
minSdk = 26
targetSdk = 35
versionCode = 8
versionName = "v1.2.0"
versionCode = 9
versionName = "v1.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+1
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name="com.chaquo.python.android.PyApplication"
@@ -129,28 +129,65 @@ object AppUpdateManager {
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")
var currentUrl = downloadUrl
var conn: HttpURLConnection? = null
var redirects = 0
val maxRedirects = 6
while (redirects < maxRedirects) {
val u = URL(currentUrl)
val c = (u.openConnection() as HttpURLConnection).apply {
instanceFollowRedirects = true
connectTimeout = 15000
readTimeout = 25000
setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
}
val code = c.responseCode
if (code == HttpURLConnection.HTTP_MOVED_PERM ||
code == HttpURLConnection.HTTP_MOVED_TEMP ||
code == HttpURLConnection.HTTP_SEE_OTHER ||
code == 307 || code == 308
) {
val location = c.getHeaderField("Location")
?: throw java.io.IOException("Redirect with no Location header")
c.disconnect()
currentUrl = if (location.startsWith("http://") || location.startsWith("https://")) {
location
} else {
URL(u, location).toString()
}
redirects++
} else if (code in 200..299) {
conn = c
break
} else {
c.disconnect()
throw java.io.IOException("HTTP error $code: ${c.responseMessage}")
}
}
val fileLength = conn.contentLength
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir
val apkFile = File(dir, "Escape-penacony-update.apk")
val finalConn = conn ?: throw java.io.IOException("Too many redirects: $redirects")
val fileLength = finalConn.contentLength
val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
val apkFile = File(updateDir, "Escape-penacony-update.apk")
if (apkFile.exists()) apkFile.delete()
conn.inputStream.use { input ->
finalConn.inputStream.use { input ->
FileOutputStream(apkFile).use { output ->
val buffer = ByteArray(8 * 1024)
val buffer = ByteArray(16 * 1024)
var total: Long = 0
var count: Int
var lastProgress = -1
while (input.read(buffer).also { count = it } != -1) {
total += count
if (fileLength > 0) {
val progress = ((total * 100) / fileLength).toInt()
withContext(Dispatchers.Main) {
onProgress(progress)
val progress = ((total * 100) / fileLength).toInt().coerceIn(0, 100)
if (progress != lastProgress) {
lastProgress = progress
withContext(Dispatchers.Main) {
onProgress(progress)
}
}
}
output.write(buffer, 0, count)
@@ -158,36 +195,56 @@ object AppUpdateManager {
}
}
if (!apkFile.exists() || apkFile.length() == 0L) {
throw java.io.IOException("Downloaded APK file is empty")
}
Result.success(apkFile)
} catch (e: Exception) {
Result.failure(e)
}
}
fun promptInstallApk(context: Context, apkFile: File) {
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
fun promptInstallApk(context: Context, apkFile: File): Result<Unit> {
return try {
if (!apkFile.exists() || apkFile.length() == 0L) {
return Result.failure(IllegalStateException("Downloaded APK file is empty or missing"))
}
}
val apkUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
try {
val settingsIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(settingsIntent)
} catch (e: Exception) {
val fallbackIntent = Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallbackIntent)
}
return Result.failure(SecurityException("Please allow installing unknown apps from this source in Settings, then click Update again."))
}
}
val 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)
val apkUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(installIntent)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
context.startActivity(installIntent)
}
fun isNewerVersion(current: String, latest: String): Boolean {
@@ -12,8 +12,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.filled.List
@@ -461,7 +463,16 @@ fun MainScreen() {
}
isDownloadingApk = false
res.onSuccess { apkFile ->
AppUpdateManager.promptInstallApk(context, apkFile)
AppUpdateManager.promptInstallApk(context, apkFile).onFailure { err ->
snackbarHostState.showSnackbar(
tr(
currentLanguage,
"Unable to launch installer: ${err.message}",
"ไม่สามารถเปิดการติดตั้งได้: ${err.message}",
"无法启动安装程序: ${err.message}"
)
)
}
}.onFailure {
snackbarHostState.showSnackbar(
tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}")
@@ -928,20 +939,56 @@ fun MainScreen() {
text = {
Column {
Text(
tr(currentLanguage, "Current: ${BuildConfig.VERSION_NAME} -> Latest: ${release.tagName}", "เวอร์ชันปัจจุบัน: ${BuildConfig.VERSION_NAME} -> ล่าสุด: ${release.tagName}", "当前版本: ${BuildConfig.VERSION_NAME} -> 最新: ${release.tagName}"),
tr(
currentLanguage,
"Current: ${BuildConfig.VERSION_NAME} -> Latest: ${release.tagName}",
"เวอร์ชันปัจจุบัน: ${BuildConfig.VERSION_NAME} -> ล่าสุด: ${release.tagName}",
"当前版本: ${BuildConfig.VERSION_NAME} -> 最新: ${release.tagName}"
),
fontSize = 13.sp,
color = Color.LightGray
)
if (release.changelog.isNotBlank()) {
if (release.releaseTitle.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
release.changelog,
fontSize = 12.sp,
color = Color.Gray,
maxLines = 6,
overflow = TextOverflow.Ellipsis
text = release.releaseTitle,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
if (release.changelog.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = tr(currentLanguage, "Changelog:", "บันทึกการเปลี่ยนแปลง:", "更新日志:"),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFA0AEC0)
)
Spacer(modifier = Modifier.height(4.dp))
val changelogScrollState = rememberScrollState()
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 60.dp, max = 220.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF141721)),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(changelogScrollState)
.padding(10.dp)
) {
Text(
text = release.changelog,
fontSize = 12.sp,
color = Color(0xFFD1D5DB),
lineHeight = 18.sp
)
}
}
}
if (isDownloadingApk) {
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator(
@@ -951,7 +998,12 @@ fun MainScreen() {
)
Spacer(modifier = Modifier.height(4.dp))
Text(
tr(currentLanguage, "Downloading APK: $apkDownloadProgress%", "กำลังดาวน์โหลดไฟล์ APK: $apkDownloadProgress%", "正在下载安装包: $apkDownloadProgress%"),
tr(
currentLanguage,
"Downloading APK: $apkDownloadProgress%",
"กำลังดาวน์โหลดไฟล์ APK: $apkDownloadProgress%",
"正在下载安装包: $apkDownloadProgress%"
),
fontSize = 11.sp,
color = Color.Gray
)
@@ -970,7 +1022,16 @@ fun MainScreen() {
isDownloadingApk = false
res.onSuccess { apkFile ->
showAppUpdateDialog = false
AppUpdateManager.promptInstallApk(context, apkFile)
AppUpdateManager.promptInstallApk(context, apkFile).onFailure { err ->
snackbarHostState.showSnackbar(
tr(
currentLanguage,
"Unable to launch installer: ${err.message}",
"ไม่สามารถเปิดการติดตั้งได้: ${err.message}",
"无法启动安装程序: ${err.message}"
)
)
}
}.onFailure {
snackbarHostState.showSnackbar(
tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}")
@@ -1785,6 +1846,16 @@ fun UpdaterTab(
color = PurplePrimary
)
}
if (appReleaseInfo != null && appReleaseInfo.releaseTitle.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = appReleaseInfo.releaseTitle,
fontSize = 11.sp,
color = Color.LightGray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
+5 -2
View File
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="downloads" path="Download/" />
<cache-path name="cache" path="." />
<files-path name="internal_files" path="." />
<cache-path name="internal_cache" path="." />
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>