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 @@ - - + + + + +