diff --git a/README.md b/README.md
index e348e84..8fd5185 100644
--- a/README.md
+++ b/README.md
@@ -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]
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c5f3fd8..1efcca0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f62f12f..117ec3a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
Unit
): Result = 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 {
+ 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 {
diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt
index d19ad67..c9111d3 100644
--- a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt
+++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt
@@ -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
+ )
+ }
}
}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index ed0c8cb..d60245e 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -1,5 +1,8 @@
-
-
+
+
+
+
+