diff --git a/README.md b/README.md index bf61749..749fcb6 100644 --- a/README.md +++ b/README.md @@ -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 ฯลฯ) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 009d539..00aeb35 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 = 3 - versionName = "v1.1.0" + versionCode = 4 + versionName = "v1.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/assets/chaquopy/app.imy b/app/src/main/assets/chaquopy/app.imy index 8c66537..c61e1e5 100644 Binary files a/app/src/main/assets/chaquopy/app.imy and b/app/src/main/assets/chaquopy/app.imy differ diff --git a/app/src/main/assets/packages.zip b/app/src/main/assets/packages.zip new file mode 100644 index 0000000..31aef34 Binary files /dev/null and b/app/src/main/assets/packages.zip differ diff --git a/app/src/main/java/com/catlas/escapepenacony/data/AppUpdateManager.kt b/app/src/main/java/com/catlas/escapepenacony/data/AppUpdateManager.kt new file mode 100644 index 0000000..fa74f64 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/data/AppUpdateManager.kt @@ -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 = + 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 = 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 + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt b/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt index 00a4609..5b8f4c3 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt @@ -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 diff --git a/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt b/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt index 850570f..0f15f97 100644 --- a/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt +++ b/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt @@ -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 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 189b6ce..3eecfca 100644 --- a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt +++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt @@ -4,8 +4,8 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.widget.Toast +import com.catlas.escapepenacony.BuildConfig import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -15,6 +15,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,10 +39,29 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File -enum class ScreenTab(val titleEn: String, val titleTh: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) { - SERVER("Server", "เซิร์ฟเวอร์", Icons.Default.Computer), - TELEPORT("Teleport", "วาร์ปแผนที่", Icons.Default.Explore), - UPDATER("Updater", "อัปเดต", Icons.Default.CloudDownload) +fun tr(lang: AppLanguage, en: String, th: String, zh: String = en): String { + return when (lang) { + AppLanguage.TH -> th + AppLanguage.ZH -> zh + AppLanguage.EN -> en + } +} + +enum class ScreenTab( + val titleEn: String, + val titleTh: String, + val titleZh: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector +) { + SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer), + TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore), + UPDATER("Updater", "อัปเดต", "更新", Icons.Default.CloudDownload); + + fun localizedTitle(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> titleTh + AppLanguage.ZH -> titleZh + AppLanguage.EN -> titleEn + } } @OptIn(ExperimentalMaterial3Api::class) @@ -92,6 +113,13 @@ fun MainScreen() { var installProgress by remember { mutableStateOf(0f) } var installStatusMessage by remember { mutableStateOf("") } + // App Update States (APK) + var appReleaseInfo by remember { mutableStateOf(null) } + var showAppUpdateDialog by remember { mutableStateOf(false) } + var isCheckingAppUpdate by remember { mutableStateOf(false) } + var isDownloadingApk by remember { mutableStateOf(false) } + var apkDownloadProgress by remember { mutableStateOf(0) } + // Load initial data LaunchedEffect(Unit) { planets = SceneRepository.loadScenes(context) @@ -108,6 +136,19 @@ fun MainScreen() { currentSceneConfig = SceneConfig.fromJsonObject(it) } } + + // Silent background check for APK updates on launch + scope.launch { + val res = AppUpdateManager.checkForAppUpdate() + res.onSuccess { info -> + if (info != null) { + appReleaseInfo = info + if (info.isNewer) { + showAppUpdateDialog = true + } + } + } + } } val snackbarHostState = remember { SnackbarHostState() } @@ -119,9 +160,10 @@ fun MainScreen() { title = { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "ppSR Standalone", + text = "ppSR & Escape Penacony", fontWeight = FontWeight.Bold, - color = Color.White + color = Color.White, + fontSize = 17.sp ) Spacer(modifier = Modifier.width(8.dp)) Surface( @@ -133,8 +175,15 @@ fun MainScreen() { ServerStatus.STOPPED -> Color.Gray.copy(alpha = 0.2f) } ) { + val statusLabel = when (serverStatus) { + ServerStatus.RUNNING -> tr(currentLanguage, "RUNNING", "กำลังทำงาน", "运行中") + ServerStatus.STARTING -> tr(currentLanguage, "STARTING", "กำลังเริ่ม", "启动中") + ServerStatus.STOPPING -> tr(currentLanguage, "STOPPING", "กำลังหยุด", "停止中") + ServerStatus.ERROR -> tr(currentLanguage, "ERROR", "ข้อผิดพลาด", "错误") + ServerStatus.STOPPED -> tr(currentLanguage, "STOPPED", "หยุดทำงาน", "已停止") + } Text( - text = serverStatus.name, + text = statusLabel, color = when (serverStatus) { ServerStatus.RUNNING -> CombatGreen ServerStatus.STARTING, ServerStatus.STOPPING -> ExploreAmber @@ -149,7 +198,7 @@ fun MainScreen() { } }, actions = { - // Language Switcher + // Language Switcher Pill TextButton(onClick = { currentLanguage = when (currentLanguage) { AppLanguage.TH -> AppLanguage.EN @@ -175,7 +224,7 @@ fun MainScreen() { onClick = { selectedTab = tab }, icon = { Icon(tab.icon, contentDescription = tab.titleEn) }, label = { - Text(if (currentLanguage == AppLanguage.TH) tab.titleTh else tab.titleEn) + Text(tab.localizedTitle(currentLanguage)) }, colors = NavigationBarItemDefaults.colors( selectedIconColor = PurplePrimary, @@ -197,6 +246,7 @@ fun MainScreen() { ScreenTab.SERVER -> { ServerControlTab( context = context, + currentLanguage = currentLanguage, serverStatus = serverStatus, isServerInstalled = isServerInstalled, sendHotfix = sendHotfix, @@ -227,7 +277,7 @@ fun MainScreen() { val clip = ClipData.newPlainText("ppSR Logs", serverLogs.joinToString("\n")) val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(clip) - Toast.makeText(context, "Logs copied to clipboard", Toast.LENGTH_SHORT).show() + Toast.makeText(context, tr(currentLanguage, "Logs copied to clipboard", "คัดลอกบันทึกแล้ว", "日志已复制"), Toast.LENGTH_SHORT).show() } ) } @@ -247,7 +297,7 @@ fun MainScreen() { scope.launch { val wasRunning = serverStatus == ServerStatus.RUNNING if (wasRunning) { - snackbarHostState.showSnackbar("Pausing server to update scene...") + snackbarHostState.showSnackbar(tr(currentLanguage, "Pausing server to update scene...", "กำลังพักเซิร์ฟเวอร์เพื่อเปลี่ยนฉาก...", "正在暂停服务器更新场景...")) ServerService.stop(context) delay(1000) } @@ -274,13 +324,13 @@ fun MainScreen() { val res = persistentRepo.applySceneTeleport(payload) if (res.isSuccess) { currentSceneConfig = SceneConfig.fromScene(scene) - snackbarHostState.showSnackbar("Teleported to ${scene.localizedName(currentLanguage)}!") + snackbarHostState.showSnackbar(tr(currentLanguage, "Teleported to ${scene.localizedName(currentLanguage)}!", "วาร์ปไป ${scene.localizedName(currentLanguage)} สำเร็จ!", "已传送至 ${scene.localizedName(currentLanguage)}!")) if (wasRunning) { delay(500) ServerService.start(context) } } else { - snackbarHostState.showSnackbar("Failed: ${res.exceptionOrNull()?.message}") + snackbarHostState.showSnackbar(tr(currentLanguage, "Failed: ${res.exceptionOrNull()?.message}", "ล้มเหลว: ${res.exceptionOrNull()?.message}", "失败: ${res.exceptionOrNull()?.message}")) } } }, @@ -290,12 +340,17 @@ fun MainScreen() { ScreenTab.UPDATER -> { UpdaterTab( context = context, + currentLanguage = currentLanguage, installedVersion = installedVersion, updateInfo = updateInfo, isChecking = isCheckingUpdate, isInstalling = isInstalling, installProgress = installProgress, installMessage = installStatusMessage, + appReleaseInfo = appReleaseInfo, + isCheckingApp = isCheckingAppUpdate, + isDownloadingApk = isDownloadingApk, + apkProgress = apkDownloadProgress, onCheckUpdate = { scope.launch { isCheckingUpdate = true @@ -303,9 +358,9 @@ fun MainScreen() { isCheckingUpdate = false if (res.isSuccess) { updateInfo = res.getOrNull() - snackbarHostState.showSnackbar("Remote latest: ${updateInfo?.latestVersion}") + snackbarHostState.showSnackbar(tr(currentLanguage, "Remote latest: ${updateInfo?.latestVersion}", "เวอร์ชันล่าสุดบนเซิร์ฟเวอร์: ${updateInfo?.latestVersion}", "远程最新版本: ${updateInfo?.latestVersion}")) } else { - snackbarHostState.showSnackbar("Check failed: ${res.exceptionOrNull()?.message}") + snackbarHostState.showSnackbar(tr(currentLanguage, "Check failed: ${res.exceptionOrNull()?.message}", "ตรวจหาไม่สำเร็จ: ${res.exceptionOrNull()?.message}", "检查失败: ${res.exceptionOrNull()?.message}")) } } }, @@ -316,15 +371,16 @@ fun MainScreen() { isCheckingUpdate = false if (res.isSuccess) { val versions = res.getOrNull() ?: emptyList() + val latest = versions.lastOrNull() ?: "" updateInfo = UpdateInfo( currentVersion = installedVersion, - latestVersion = versions.lastOrNull() ?: "", + latestVersion = latest, availableVersions = versions, - hasUpdate = true + hasUpdate = installedVersion.isBlank() || installedVersion != latest ) showVersionSelectorDialog = true } else { - snackbarHostState.showSnackbar("Failed to fetch versions") + snackbarHostState.showSnackbar(tr(currentLanguage, "Failed to fetch versions", "ไม่สามารถดึงรายการเวอร์ชันได้", "获取版本列表失败")) } } }, @@ -343,9 +399,55 @@ fun MainScreen() { if (res.isSuccess) { installedVersion = versionToInstall isServerInstalled = true - snackbarHostState.showSnackbar("Server v$versionToInstall installed successfully!") + // Refresh updateInfo state + updateInfo?.let { prev -> + updateInfo = prev.copy( + currentVersion = versionToInstall, + hasUpdate = versionToInstall != prev.latestVersion + ) + } + snackbarHostState.showSnackbar(tr(currentLanguage, "Server v$versionToInstall installed successfully!", "ติดตั้งเซิร์ฟเวอร์ v$versionToInstall สำเร็จแล้ว!", "服务器 v$versionToInstall 安装成功!")) } else { - snackbarHostState.showSnackbar("Install error: ${res.exceptionOrNull()?.message}") + snackbarHostState.showSnackbar(tr(currentLanguage, "Install error: ${res.exceptionOrNull()?.message}", "ติดตั้งล้มเหลว: ${res.exceptionOrNull()?.message}", "安装错误: ${res.exceptionOrNull()?.message}")) + } + } + }, + onCheckAppUpdate = { + scope.launch { + isCheckingAppUpdate = true + val res = AppUpdateManager.checkForAppUpdate() + isCheckingAppUpdate = false + res.onSuccess { info -> + appReleaseInfo = info + if (info != null && info.isNewer) { + showAppUpdateDialog = true + } else { + snackbarHostState.showSnackbar( + tr(currentLanguage, "App is already up to date (${BuildConfig.VERSION_NAME})", "แอปพลิเคชันเป็นเวอร์ชันล่าสุดแล้ว (${BuildConfig.VERSION_NAME})", "应用程序已是最新版本 (${BuildConfig.VERSION_NAME})") + ) + } + }.onFailure { + snackbarHostState.showSnackbar( + tr(currentLanguage, "Check failed: ${it.message}", "ตรวจหาไม่สำเร็จ: ${it.message}", "检查失败: ${it.message}") + ) + } + } + }, + onDownloadApk = { + appReleaseInfo?.apkDownloadUrl?.let { url -> + scope.launch { + isDownloadingApk = true + val res = AppUpdateManager.downloadApk(context, url) { p -> + apkDownloadProgress = p + } + isDownloadingApk = false + res.onSuccess { apkFile -> + AppUpdateManager.promptInstallApk(context, apkFile) + }.onFailure { + snackbarHostState.showSnackbar( + tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}") + ) + } } } } @@ -355,22 +457,26 @@ fun MainScreen() { } } - // --- Dialogs --- + // --- Dialogs with Full Localization --- // Password Dialog if (showPasswordDialog) { var newKey by remember { mutableStateOf(serverKey) } AlertDialog( onDismissRequest = { showPasswordDialog = false }, - title = { Text("Set Server Password / Key") }, + title = { Text(tr(currentLanguage, "Set Server Password / Key", "ตั้งรหัสผ่าน / คีย์เซิร์ฟเวอร์", "设置服务器密码/密钥")) }, text = { Column { - Text("Leave blank if no password is required by your client.", fontSize = 13.sp, color = Color.Gray) + Text( + tr(currentLanguage, "Leave blank if no password is required by your client.", "ปล่อยว่างไว้หากตัวเกมไคลเอนต์ไม่ต้องการรหัสผ่าน", "如果客户端不需要密码,请留空。"), + fontSize = 13.sp, + color = Color.Gray + ) Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( value = newKey, onValueChange = { newKey = it }, - label = { Text("Key / Password") }, + label = { Text(tr(currentLanguage, "Key / Password", "คีย์ / รหัสผ่าน", "密钥 / 密码")) }, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -382,14 +488,16 @@ fun MainScreen() { persistentRepo.setServerKey(newKey) serverKey = newKey showPasswordDialog = false - snackbarHostState.showSnackbar("Password updated") + snackbarHostState.showSnackbar(tr(currentLanguage, "Password updated", "บันทึกรหัสผ่านแล้ว", "密码已更新")) } }) { - Text("Save") + Text(tr(currentLanguage, "Save", "บันทึก", "保存")) } }, dismissButton = { - TextButton(onClick = { showPasswordDialog = false }) { Text("Cancel") } + TextButton(onClick = { showPasswordDialog = false }) { + Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消")) + } } ) } @@ -405,24 +513,24 @@ fun MainScreen() { AlertDialog( onDismissRequest = { showTrailblazerDialog = false }, - title = { Text("Edit Trailblazer (ตัวละคร)") }, + title = { Text(tr(currentLanguage, "Edit Trailblazer (ตัวละคร)", "แก้ไขตัวละครผู้บุกเบิก (Trailblazer)", "编辑开拓者角色")) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Trailblazer Gender:", fontWeight = FontWeight.Bold, fontSize = 13.sp) + Text(tr(currentLanguage, "Trailblazer Gender:", "เพศตัวเอก:", "开拓者性别:"), fontWeight = FontWeight.Bold, fontSize = 13.sp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = gender == "Female", onClick = { gender = "Female" }, - label = { Text("Female (Stelle)") } + label = { Text(tr(currentLanguage, "Female (Stelle)", "หญิง (Stelle)", "女性 (星)")) } ) FilterChip( selected = gender == "Male", onClick = { gender = "Male" }, - label = { Text("Male (Caelus)") } + label = { Text(tr(currentLanguage, "Male (Caelus)", "ชาย (Caelus)", "男性 (穹)")) } ) } - Text("Trailblazer Path:", fontWeight = FontWeight.Bold, fontSize = 13.sp) + Text(tr(currentLanguage, "Trailblazer Path:", "Path ของตัวเอก:", "开拓者命途:"), fontWeight = FontWeight.Bold, fontSize = 13.sp) LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { items(paths) { p -> FilterChip( @@ -433,7 +541,7 @@ fun MainScreen() { } } - Text("March 7th Path:", fontWeight = FontWeight.Bold, fontSize = 13.sp) + Text(tr(currentLanguage, "March 7th Path:", "Path ของ March 7th:", "三月七命途:"), fontWeight = FontWeight.Bold, fontSize = 13.sp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { marchPaths.forEach { mp -> FilterChip( @@ -452,14 +560,16 @@ fun MainScreen() { persistentRepo.setTrailblazerConfig(newCfg) trailblazerConfig = newCfg showTrailblazerDialog = false - snackbarHostState.showSnackbar("Trailblazer profile saved") + snackbarHostState.showSnackbar(tr(currentLanguage, "Trailblazer profile saved", "บันทึกข้อมูลตัวละครแล้ว", "主角配置已保存")) } }) { - Text("Save") + Text(tr(currentLanguage, "Save", "บันทึก", "保存")) } }, dismissButton = { - TextButton(onClick = { showTrailblazerDialog = false }) { Text("Cancel") } + TextButton(onClick = { showTrailblazerDialog = false }) { + Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消")) + } } ) } @@ -471,7 +581,14 @@ fun MainScreen() { showMiscExplorerDialog = false selectedMiscFile = null }, - title = { Text(if (selectedMiscFile == null) "Misc Files" else "Edit: ${selectedMiscFile?.name}") }, + title = { + Text( + if (selectedMiscFile == null) + tr(currentLanguage, "Misc Files Explorer", "จัดการไฟล์ในโฟลเดอร์ Misc", "杂项文件管理器") + else + "${tr(currentLanguage, "Edit", "แก้ไข", "编辑")}: ${selectedMiscFile?.name}" + ) + }, text = { if (selectedMiscFile == null) { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 350.dp)) { @@ -492,7 +609,7 @@ fun MainScreen() { modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.InsertDriveFile, contentDescription = null, tint = PurplePrimary) + Icon(Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null, tint = PurplePrimary) Spacer(modifier = Modifier.width(10.dp)) Column { Text(file.name, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) @@ -519,21 +636,25 @@ fun MainScreen() { scope.launch { selectedMiscFile?.let { file -> persistentRepo.writeMiscFile(file.name, miscFileContent) - snackbarHostState.showSnackbar("${file.name} saved successfully") + snackbarHostState.showSnackbar("${file.name} ${tr(currentLanguage, "saved successfully", "บันทึกสำเร็จ", "保存成功")}") selectedMiscFile = null miscFiles = persistentRepo.listMiscFiles() } } }) { - Text("Save File") + Text(tr(currentLanguage, "Save File", "บันทึกไฟล์", "保存文件")) } } else { - TextButton(onClick = { showMiscExplorerDialog = false }) { Text("Close") } + TextButton(onClick = { showMiscExplorerDialog = false }) { + Text(tr(currentLanguage, "Close", "ปิด", "关闭")) + } } }, dismissButton = { if (selectedMiscFile != null) { - TextButton(onClick = { selectedMiscFile = null }) { Text("Back to list") } + TextButton(onClick = { selectedMiscFile = null }) { + Text(tr(currentLanguage, "Back to list", "ย้อนกลับ", "返回列表")) + } } } ) @@ -544,7 +665,7 @@ fun MainScreen() { val versions = updateInfo?.availableVersions ?: emptyList() AlertDialog( onDismissRequest = { showVersionSelectorDialog = false }, - title = { Text("Select Server Version") }, + title = { Text(tr(currentLanguage, "Select ppSR Server Version", "เลือกเวอร์ชันเซิร์ฟเวอร์ ppSR", "选择 ppSR 服务器版本")) }, text = { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp)) { items(versions.reversed()) { ver -> @@ -555,6 +676,10 @@ fun MainScreen() { .clickable { showVersionSelectorDialog = false scope.launch { + if (serverStatus == ServerStatus.RUNNING) { + ServerService.stop(context) + delay(1000) + } isInstalling = true val res = updateRepo.downloadAndInstall(ver) { p, msg -> installProgress = p @@ -564,9 +689,15 @@ fun MainScreen() { if (res.isSuccess) { installedVersion = ver isServerInstalled = true - snackbarHostState.showSnackbar("v$ver installed successfully!") + updateInfo?.let { prev -> + updateInfo = prev.copy( + currentVersion = ver, + hasUpdate = ver != prev.latestVersion + ) + } + snackbarHostState.showSnackbar("v$ver ${tr(currentLanguage, "installed successfully!", "ติดตั้งสำเร็จแล้ว!", "安装成功!")}") } else { - snackbarHostState.showSnackbar("Failed: ${res.exceptionOrNull()?.message}") + snackbarHostState.showSnackbar("${tr(currentLanguage, "Failed", "ล้มเหลว", "失败")}: ${res.exceptionOrNull()?.message}") } } } @@ -580,7 +711,11 @@ fun MainScreen() { modifier = Modifier.weight(1f) ) if (isInstalled) { - Text("(Active)", fontSize = 12.sp, color = CombatGreen) + Text( + "(${tr(currentLanguage, "Active", "กำลังใช้งาน", "当前使用")})", + fontSize = 12.sp, + color = CombatGreen + ) } } HorizontalDivider(color = Color.DarkGray) @@ -588,7 +723,91 @@ fun MainScreen() { } }, confirmButton = { - TextButton(onClick = { showVersionSelectorDialog = false }) { Text("Close") } + TextButton(onClick = { showVersionSelectorDialog = false }) { + Text(tr(currentLanguage, "Close", "ปิด", "关闭")) + } + } + ) + } + + // App Update Dialog (APK) + if (showAppUpdateDialog && appReleaseInfo != null) { + val release = appReleaseInfo!! + AlertDialog( + onDismissRequest = { if (!isDownloadingApk) showAppUpdateDialog = false }, + title = { + Text( + tr(currentLanguage, "New App Update: ${release.tagName}", "พบอัปเดตแอปใหม่: ${release.tagName}", "发现新版本应用: ${release.tagName}"), + fontWeight = FontWeight.Bold, + color = Color.White + ) + }, + text = { + Column { + Text( + 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()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + release.changelog, + fontSize = 12.sp, + color = Color.Gray, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + if (isDownloadingApk) { + Spacer(modifier = Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { apkDownloadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = PurplePrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + tr(currentLanguage, "Downloading APK: $apkDownloadProgress%", "กำลังดาวน์โหลดไฟล์ APK: $apkDownloadProgress%", "正在下载安装包: $apkDownloadProgress%"), + fontSize = 11.sp, + color = Color.Gray + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (!isDownloadingApk && release.apkDownloadUrl.isNotBlank()) { + scope.launch { + isDownloadingApk = true + val res = AppUpdateManager.downloadApk(context, release.apkDownloadUrl) { p -> + apkDownloadProgress = p + } + isDownloadingApk = false + res.onSuccess { apkFile -> + showAppUpdateDialog = false + AppUpdateManager.promptInstallApk(context, apkFile) + }.onFailure { + snackbarHostState.showSnackbar( + tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}") + ) + } + } + } + }, + enabled = !isDownloadingApk && release.apkDownloadUrl.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = CombatGreen) + ) { + Text(tr(currentLanguage, "Update Now", "อัปเดตทันที", "立即更新")) + } + }, + dismissButton = { + if (!isDownloadingApk) { + TextButton(onClick = { showAppUpdateDialog = false }) { + Text(tr(currentLanguage, "Later", "ไว้ภายหลัง", "稍后")) + } + } } ) } @@ -603,7 +822,7 @@ fun MainScreen() { AlertDialog( onDismissRequest = { showCustomCoordsDialog = false }, - title = { Text("Custom Coordinates (กำหนดพิกัดเอง)") }, + title = { Text(tr(currentLanguage, "Custom Coordinates", "กำหนดพิกัดเอง (Custom Coordinates)", "自定义坐标")) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( @@ -639,7 +858,7 @@ fun MainScreen() { OutlinedTextField( value = layerStr, onValueChange = { layerStr = it }, - label = { Text("Map Layer (1, 2, 3...)") }, + label = { Text(tr(currentLanguage, "Map Layer (1, 2, 3...)", "ชั้นของแผนที่ (Map Layer)", "地图层级 (1, 2, 3...)")) }, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -680,7 +899,7 @@ fun MainScreen() { z = z.toLong(), mapLayer = layer ) - snackbarHostState.showSnackbar("Custom scene coordinates applied!") + snackbarHostState.showSnackbar(tr(currentLanguage, "Custom scene coordinates applied!", "บันทึกพิกัดฉากเรียบร้อยแล้ว!", "已应用自定义场景坐标!")) if (wasRunning) { delay(500) ServerService.start(context) @@ -688,11 +907,13 @@ fun MainScreen() { } } }) { - Text("Apply") + Text(tr(currentLanguage, "Apply", "นำไปใช้", "应用")) } }, dismissButton = { - TextButton(onClick = { showCustomCoordsDialog = false }) { Text("Cancel") } + TextButton(onClick = { showCustomCoordsDialog = false }) { + Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消")) + } } ) } @@ -701,6 +922,7 @@ fun MainScreen() { @Composable fun ServerControlTab( context: Context, + currentLanguage: AppLanguage, serverStatus: ServerStatus, isServerInstalled: Boolean, sendHotfix: Boolean, @@ -746,17 +968,21 @@ fun ServerControlTab( verticalAlignment = Alignment.CenterVertically ) { Column { - Text("Server Status", fontSize = 12.sp, color = Color.Gray) + Text( + tr(currentLanguage, "Server Status", "สถานะเซิร์ฟเวอร์", "服务器状态"), + fontSize = 12.sp, + color = Color.Gray + ) Text( text = when (serverStatus) { - ServerStatus.RUNNING -> "🟢 Running (Online)" - ServerStatus.STARTING -> "🟡 Starting..." - ServerStatus.STOPPING -> "🟠 Stopping..." - ServerStatus.ERROR -> "🔴 Error" - ServerStatus.STOPPED -> "⚪ Stopped (Offline)" + ServerStatus.RUNNING -> tr(currentLanguage, "🟢 Running (Online)", "🟢 กำลังทำงาน (ออนไลน์)", "🟢 运行中 (在线)") + ServerStatus.STARTING -> tr(currentLanguage, "🟡 Starting...", "🟡 กำลังเริ่มเซิร์ฟเวอร์...", "🟡 正在启动...") + ServerStatus.STOPPING -> tr(currentLanguage, "🟠 Stopping...", "🟠 กำลังหยุดเซิร์ฟเวอร์...", "🟠 正在停止...") + ServerStatus.ERROR -> tr(currentLanguage, "🔴 Error", "🔴 ข้อผิดพลาด", "🔴 错误") + ServerStatus.STOPPED -> tr(currentLanguage, "⚪ Stopped (Offline)", "⚪ ปิดอยู่ (ออฟไลน์)", "⚪ 已停止 (离线)") }, fontWeight = FontWeight.Bold, - fontSize = 18.sp, + fontSize = 17.sp, color = Color.White ) } @@ -769,7 +995,7 @@ fun ServerControlTab( ) { Icon(Icons.Default.Stop, contentDescription = null) Spacer(modifier = Modifier.width(4.dp)) - Text("Stop") + Text(tr(currentLanguage, "Stop", "หยุด", "停止")) } IconButton(onClick = onRestartServer) { Icon(Icons.Default.Refresh, contentDescription = "Restart", tint = PurplePrimary) @@ -782,7 +1008,7 @@ fun ServerControlTab( ) { Icon(Icons.Default.PlayArrow, contentDescription = null) Spacer(modifier = Modifier.width(4.dp)) - Text("Start") + Text(tr(currentLanguage, "Start", "เริ่ม", "启动")) } } } @@ -809,7 +1035,12 @@ fun ServerControlTab( shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(14.dp)) { - Text("Server Controls & Configuration", fontWeight = FontWeight.Bold, fontSize = 14.sp, color = Color.White) + Text( + tr(currentLanguage, "Server Controls & Configuration", "การควบคุม & ตั้งค่าเซิร์ฟเวอร์", "服务器控制与设置"), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = Color.White + ) Spacer(modifier = Modifier.height(10.dp)) Row( @@ -823,7 +1054,12 @@ fun ServerControlTab( ) { Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text(if (serverKey.isBlank()) "Password" else "Key Set", fontSize = 12.sp, maxLines = 1) + Text( + if (serverKey.isBlank()) tr(currentLanguage, "Password", "รหัสผ่าน", "密码") + else tr(currentLanguage, "Key Set", "มีรหัสแล้ว", "已设密码"), + fontSize = 12.sp, + maxLines = 1 + ) } OutlinedButton( @@ -833,7 +1069,7 @@ fun ServerControlTab( ) { Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Trailblazer", fontSize = 12.sp, maxLines = 1) + Text(tr(currentLanguage, "Trailblazer", "ตัวละคร", "主角"), fontSize = 12.sp, maxLines = 1) } OutlinedButton( @@ -843,7 +1079,7 @@ fun ServerControlTab( ) { Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Misc Files", fontSize = 12.sp, maxLines = 1) + Text(tr(currentLanguage, "Misc Files", "ไฟล์ Misc", "杂项文件"), fontSize = 12.sp, maxLines = 1) } } @@ -855,10 +1091,17 @@ fun ServerControlTab( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column { - Text("Send Hotfix (โหมดดาวน์โหลดแพตช์)", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Column(modifier = Modifier.weight(1f)) { Text( - if (sendHotfix) "เปิด: ต่อเน็ตโหลด Asset จาก Hoyoverse" else "ปิด: ออฟไลน์ ไม่ดึง Hotfix จากเน็ต", + tr(currentLanguage, "Send Hotfix (Asset Patching)", "Send Hotfix (โหมดดาวน์โหลดแพตช์)", "Send Hotfix (补丁模式)"), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + Text( + if (sendHotfix) + tr(currentLanguage, "Enabled: Online hoyoverse assets", "เปิด: ต่อเน็ตโหลด Asset จาก Hoyoverse", "开启: 在线获取官方资源") + else + tr(currentLanguage, "Disabled: 100% Offline (fixes black screen)", "ปิด: ออฟไลน์ 100% (แก้ปัญหาจอดำ)", "关闭: 100% 离线 (修复黑屏)"), fontSize = 11.sp, color = if (sendHotfix) ExploreAmber else CombatGreen ) @@ -893,7 +1136,12 @@ fun ServerControlTab( .background(if (serverStatus == ServerStatus.RUNNING) CombatGreen else Color.Gray) ) Spacer(modifier = Modifier.width(8.dp)) - Text("Console Logs", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.LightGray) + Text( + tr(currentLanguage, "Console Logs", "บันทึกคอนโซล (Console Logs)", "控制台日志"), + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + color = Color.LightGray + ) } Row { @@ -917,7 +1165,7 @@ fun ServerControlTab( if (serverLogs.isEmpty()) { item { Text( - "Server logs will appear here when started...", + tr(currentLanguage, "Server logs will appear here when started...", "บันทึกการทำงานของเซิร์ฟเวอร์จะแสดงที่นี่เมื่อเริ่มทำงาน...", "服务器启动后日志将显示在此处..."), color = Color.DarkGray, fontSize = 12.sp, fontFamily = FontFamily.Monospace @@ -998,9 +1246,13 @@ fun TeleportTab( verticalAlignment = Alignment.CenterVertically ) { Column { - Text("Current Teleport Destination", fontSize = 11.sp, color = Color.Gray) Text( - text = "Plane: ${currentSceneConfig?.planeId ?: "Unknown"} (Layer ${currentSceneConfig?.mapLayer ?: 1})", + tr(currentLanguage, "Current Teleport Destination", "พิกัดปลายทางปัจจุบัน", "当前传送目的地"), + fontSize = 11.sp, + color = Color.Gray + ) + Text( + text = "Plane: ${currentSceneConfig?.planeId ?: tr(currentLanguage, "Unknown", "ไม่ระบุ", "未知")} (Layer ${currentSceneConfig?.mapLayer ?: 1})", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = PurplePrimary @@ -1018,7 +1270,7 @@ fun TeleportTab( ) { Icon(Icons.Default.EditLocation, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Custom", fontSize = 12.sp) + Text(tr(currentLanguage, "Custom", "พิกัดเอง", "自定义"), fontSize = 12.sp) } } } @@ -1029,7 +1281,7 @@ fun TeleportTab( OutlinedTextField( value = searchQuery, onValueChange = onSearchChange, - placeholder = { Text("Search map name or plane ID...") }, + placeholder = { Text(tr(currentLanguage, "Search map name or plane ID...", "ค้นหาชื่อฉากหรือ Plane ID...", "搜索场景名称或Plane ID...")) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -1060,10 +1312,16 @@ fun TeleportTab( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FilterCategory.values().forEach { cat -> + val label = when (cat) { + FilterCategory.ALL -> tr(currentLanguage, "All", "ทั้งหมด", "全部") + FilterCategory.COMBAT -> tr(currentLanguage, "Combat", "ต่อสู้", "战斗") + FilterCategory.EXPLORE -> tr(currentLanguage, "Explore", "สำรวจ", "探索") + FilterCategory.BETA -> tr(currentLanguage, "Beta", "เบต้า", "测试") + } FilterChip( selected = selectedCategory == cat, onClick = { onCategoryChange(cat) }, - label = { Text(cat.name.capitalize()) } + label = { Text(label) } ) } } @@ -1104,7 +1362,12 @@ fun TeleportTab( shape = RoundedCornerShape(4.dp), color = CombatGreen.copy(alpha = 0.2f) ) { - Text("Combat", color = CombatGreen, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) + Text( + tr(currentLanguage, "Combat", "ต่อสู้", "战斗"), + color = CombatGreen, + fontSize = 10.sp, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) } } if (scene.isBeta) { @@ -1141,7 +1404,7 @@ fun TeleportTab( onClick = { onTeleport(scene, activePlanet?.localizedName(currentLanguage) ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary) ) { - Text("Warp") + Text(tr(currentLanguage, "Warp", "วาร์ป", "传送")) } } } @@ -1153,30 +1416,180 @@ fun TeleportTab( @Composable fun UpdaterTab( context: Context, + currentLanguage: AppLanguage, installedVersion: String, updateInfo: UpdateInfo?, isChecking: Boolean, isInstalling: Boolean, installProgress: Float, installMessage: String, + appReleaseInfo: AppReleaseInfo?, + isCheckingApp: Boolean, + isDownloadingApk: Boolean, + apkProgress: Int, onCheckUpdate: () -> Unit, onOpenVersionSelector: () -> Unit, - onInstall: (String) -> Unit + onInstall: (String) -> Unit, + onCheckAppUpdate: () -> Unit, + onDownloadApk: () -> Unit ) { + val latestVersion = updateInfo?.latestVersion ?: "" + val isInstalled = installedVersion.isNotBlank() + val isUpToDate = isInstalled && latestVersion.isNotBlank() && installedVersion == latestVersion + val hasNewVersion = isInstalled && latestVersion.isNotBlank() && installedVersion != latestVersion + Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { + // --- 1. App Updater Card (APK from GitHub) --- Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = BgCard), shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - Text("AFN Direct Updater", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Color.White) - Text("Direct synchronization from official file.garden endpoint", fontSize = 12.sp, color = Color.Gray) + Text( + tr(currentLanguage, "Escape Penacony Standalone", "แอปพลิเคชัน Escape Penacony Standalone", "Escape Penacony Standalone 客户端"), + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = Color.White + ) + Text( + tr(currentLanguage, "Application APK updater via GitHub Releases", "ตรวจหาและอัปเดตไฟล์ APK จาก GitHub Releases", "通过 GitHub Releases 更新 APK 应用程序"), + fontSize = 12.sp, + color = Color.Gray + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + tr(currentLanguage, "Current App Version", "เวอร์ชันแอปปัจจุบัน", "当前应用版本"), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = BuildConfig.VERSION_NAME, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = Color.White + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + tr(currentLanguage, "Latest on GitHub", "เวอร์ชันล่าสุดบน GitHub", "GitHub 最新版本"), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = appReleaseInfo?.tagName ?: tr(currentLanguage, "Tap check to query", "แตะตรวจหาเพื่อดูเวอร์ชัน", "点击检查获取"), + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = PurplePrimary + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + if (isDownloadingApk) { + Column(modifier = Modifier.fillMaxWidth()) { + LinearProgressIndicator( + progress = { apkProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = PurplePrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + tr(currentLanguage, "Downloading APK: $apkProgress%", "กำลังดาวน์โหลดไฟล์ APK: $apkProgress%", "正在下载安装包: $apkProgress%"), + fontSize = 12.sp, + color = Color.LightGray + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onCheckAppUpdate, + enabled = !isCheckingApp, + modifier = Modifier.weight(1f) + ) { + if (isCheckingApp) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) + } else { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(currentLanguage, "Check App Update", "ตรวจหาอัปเดตแอป", "检查应用更新")) + } + } + + if (appReleaseInfo != null && appReleaseInfo.isNewer) { + Button( + onClick = onDownloadApk, + colors = ButtonDefaults.buttonColors(containerColor = CombatGreen), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(currentLanguage, "Update App", "อัปเดตแอป", "更新应用")) + } + } + } + + if (appReleaseInfo != null && !appReleaseInfo.isNewer) { + Spacer(modifier = Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = CombatGreen.copy(alpha = 0.15f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CombatGreen, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = tr(currentLanguage, "Application is up to date (${BuildConfig.VERSION_NAME})", "แอปพลิเคชันเป็นเวอร์ชันล่าสุดแล้ว (${BuildConfig.VERSION_NAME})", "应用程序已是最新版本 (${BuildConfig.VERSION_NAME})"), + color = CombatGreen, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } + + // --- 2. ppSR Server Core Card --- + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = BgCard), + shape = RoundedCornerShape(16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + tr(currentLanguage, "ppSR Direct Updater", "ตัวอัปเดตเซิร์ฟเวอร์ ppSR โดยตรง", "ppSR 直接更新器"), + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = Color.White + ) + Text( + tr(currentLanguage, "Direct synchronization from official ppSR endpoint", "ซิงค์ข้อมูลจากเซิร์ฟเวอร์ ppSR (File Garden) โดยตรง", "直接从 ppSR 官方端点同步数据"), + fontSize = 12.sp, + color = Color.Gray + ) Spacer(modifier = Modifier.height(16.dp)) @@ -1185,9 +1598,13 @@ fun UpdaterTab( horizontalArrangement = Arrangement.SpaceBetween ) { Column { - Text("Installed Version", fontSize = 12.sp, color = Color.Gray) Text( - text = if (installedVersion.isBlank()) "None (Not Installed)" else installedVersion, + tr(currentLanguage, "Installed Version", "เวอร์ชันที่ติดตั้งในเครื่อง", "已安装版本"), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = if (installedVersion.isBlank()) tr(currentLanguage, "None (Not Installed)", "ยังไม่ได้ติดตั้ง", "未安装") else installedVersion, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = if (installedVersion.isBlank()) Color.Red else Color.White @@ -1195,9 +1612,13 @@ fun UpdaterTab( } Column(horizontalAlignment = Alignment.End) { - Text("Remote Latest", fontSize = 12.sp, color = Color.Gray) Text( - text = updateInfo?.latestVersion ?: "Tap check to fetch", + tr(currentLanguage, "Remote Latest", "เวอร์ชันล่าสุดบนเซิร์ฟเวอร์", "最新远程版本"), + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = if (latestVersion.isBlank()) tr(currentLanguage, "Tap check to fetch", "แตะปุ่มเพื่อตรวจหา", "点击检查获取") else latestVersion, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = PurplePrimary @@ -1222,6 +1643,7 @@ fun UpdaterTab( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + // Check Updates Button Button( onClick = onCheckUpdate, enabled = !isChecking, @@ -1232,19 +1654,68 @@ fun UpdaterTab( } else { Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Check Updates") + Text(tr(currentLanguage, "Check Updates", "ตรวจหาอัปเดต", "检查更新")) } } - updateInfo?.latestVersion?.let { latest -> - Button( - onClick = { onInstall(latest) }, - colors = ButtonDefaults.buttonColors(containerColor = CombatGreen), - modifier = Modifier.weight(1f) + // Action Button based on accurate version comparison + if (latestVersion.isNotBlank()) { + if (!isInstalled) { + // Not installed -> Show Install Button + Button( + onClick = { onInstall(latestVersion) }, + colors = ButtonDefaults.buttonColors(containerColor = CombatGreen), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(currentLanguage, "Install", "ติดตั้ง", "安装")) + } + } else if (hasNewVersion) { + // Has New Version -> Show Update Button + Button( + onClick = { onInstall(latestVersion) }, + colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Upgrade, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(currentLanguage, "Update", "อัปเดต", "更新")) + } + } else if (isUpToDate) { + // Up to date -> Show Reinstall Button + OutlinedButton( + onClick = { onInstall(latestVersion) }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(currentLanguage, "Reinstall", "ติดตั้งใหม่", "重新安装")) + } + } + } + } + + // Up-to-date Confirmation Badge + if (isUpToDate) { + Spacer(modifier = Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = CombatGreen.copy(alpha = 0.15f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text(if (installedVersion.isBlank()) "Install" else "Update") + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CombatGreen, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = tr(currentLanguage, "You are using the latest version ($installedVersion)", "คุณกำลังใช้เวอร์ชันล่าสุดแล้ว ($installedVersion)", "您已是最新版本 ($installedVersion)"), + color = CombatGreen, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) } } } @@ -1255,9 +1726,9 @@ fun UpdaterTab( onClick = onOpenVersionSelector, modifier = Modifier.fillMaxWidth() ) { - Icon(Icons.Default.List, contentDescription = null, modifier = Modifier.size(16.dp)) + Icon(Icons.AutoMirrored.Filled.List, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text("Select Version to Install (Archive)") + Text(tr(currentLanguage, "Select Version to Install (Archive)", "เลือกเวอร์ชันที่ต้องการติดตั้ง (คลังเวอร์ชัน)", "选择安装版本 (归档)")) } } } @@ -1270,13 +1741,30 @@ fun UpdaterTab( shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - Text("System Diagnostics", fontWeight = FontWeight.Bold, fontSize = 14.sp, color = Color.White) + Text( + tr(currentLanguage, "System Diagnostics", "ข้อมูลวินิจฉัยระบบ", "系统诊断"), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = Color.White + ) Spacer(modifier = Modifier.height(10.dp)) - DiagnosticItem(title = "Python Runtime", value = "Embedded Chaquopy Python 3.14 (arm64-v8a)") - DiagnosticItem(title = "Root Requirement", value = "None (Fully Standalone)") - DiagnosticItem(title = "Storage Sandbox", value = "${context.filesDir.absolutePath}/server") - DiagnosticItem(title = "Update Endpoint", value = UpdateRepository.VERSIONS_URL) + DiagnosticItem( + title = tr(currentLanguage, "Python Runtime", "สภาพแวดล้อม Python", "Python 运行环境"), + value = "Embedded Chaquopy Python 3.14 (arm64-v8a)" + ) + DiagnosticItem( + title = tr(currentLanguage, "Root Requirement", "ความต้องการสิทธิ์ Root", "Root 权限需求"), + value = tr(currentLanguage, "None (Fully Standalone)", "ไม่ต้องใช้ (รันในตัวแอป 100%)", "无需 (完全独立运行)") + ) + DiagnosticItem( + title = tr(currentLanguage, "Storage Sandbox", "พื้นที่จัดเก็บข้อมูลแซนด์บ็อกซ์", "存储沙盒"), + value = "${context.filesDir.absolutePath}/server" + ) + DiagnosticItem( + title = tr(currentLanguage, "Update Endpoint", "ลิงก์ตรวจสอบอัปเดต", "更新端点"), + value = UpdateRepository.VERSIONS_URL + ) } } } diff --git a/app/src/main/python/run_ppsr.py b/app/src/main/python/run_ppsr.py index 6311e3e..e3a1d04 100644 --- a/app/src/main/python/run_ppsr.py +++ b/app/src/main/python/run_ppsr.py @@ -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