From fefa57678daa5dfa7b7bd191e47e76abcf5e3ff6 Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Wed, 7 Oct 2026 23:54:00 +0700 Subject: [PATCH] feat: add explicit Root Mode button, restore backup dialog, and fix Beta category parity --- .../escapepenacony/data/SceneRepository.kt | 27 +- .../escapepenacony/data/StorageManager.kt | 38 ++- .../escapepenacony/data/UpdateManager.kt | 14 +- .../catlas/escapepenacony/ui/MainScreen.kt | 301 +++++++++++++----- 4 files changed, 291 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt b/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt index cbb6b29..b6b84b6 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt @@ -14,10 +14,28 @@ object SceneRepository { suspend fun loadScenes(context: Context): List = withContext(Dispatchers.IO) { val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME) + val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() } + val jsonContent = if (cachedFile.exists()) { - cachedFile.readText() + try { + val cachedText = cachedFile.readText() + val cachedObj = JSONObject(cachedText) + val defaultObj = JSONObject(defaultJson) + val defaultBeta = defaultObj.optInt("total_beta_scenes", 0) + val cachedBeta = cachedObj.optInt("total_beta_scenes", 0) + // Discard stale cache if bundled asset has more beta scene definitions + if (defaultBeta > cachedBeta) { + cachedFile.delete() + defaultJson + } else { + cachedText + } + } catch (e: Exception) { + cachedFile.delete() + defaultJson + } } else { - context.assets.open("scenes.json").bufferedReader().use { it.readText() } + defaultJson } parseJson(jsonContent) } @@ -81,7 +99,10 @@ object SceneRepository { val calyxPropId = sObj.optLong("calyx_prop_id", 808) val combatReady = sObj.optBoolean("calyx_supported", false) - val isBeta = sObj.optBoolean("is_beta", false) + val isBeta = sObj.optBoolean("is_beta", false) || + sObj.optString("channel", "").equals("beta", ignoreCase = true) || + sObj.optString("client_version", "").contains("Beta", ignoreCase = true) || + planeId == 20571L || planeId == 20572L val descEn = sObj.optString("desc", "") val descTh = sObj.optString("desc_th", descEn) diff --git a/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt b/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt index cde65f3..c2b99a4 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt @@ -76,6 +76,38 @@ object StorageManager { } } + suspend fun connectRootMode(): Result = withContext(Dispatchers.IO) { + try { + val hasRoot = checkRootAccess() + if (!hasRoot) { + return@withContext Result.failure(Exception("Root permission denied / กรุณากดอนุญาต Superuser ใน Magisk/KernelSU")) + } + + // Ensure ppSR directories exist + val dirCmd = "mkdir -p \"/data/data/$PPSR_PACKAGE_NAME/files/server/build/build/misc\"" + Runtime.getRuntime().exec(arrayOf("su", "-c", dirCmd)).waitFor() + + // Check if persistent.json exists; create default template if not found + val checkCmd = "[ -f \"$PPSR_INTERNAL_PERSISTENT_PATH\" ] && echo 'EXISTS'" + val p = Runtime.getRuntime().exec(arrayOf("su", "-c", checkCmd)) + val output = p.inputStream.bufferedReader().use { it.readText().trim() } + p.waitFor() + + if (output != "EXISTS") { + val initJson = "{\n \"scene\": {\n \"plane_id\": 20001,\n \"floor_id\": 20001001,\n \"entry_id\": 2000101,\n \"pos_x\": 0,\n \"pos_y\": 0,\n \"pos_z\": 0\n }\n}" + val createCmd = "echo '$initJson' > \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\"" + Runtime.getRuntime().exec(arrayOf("su", "-c", createCmd)).waitFor() + } else { + val permCmd = "chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\"" + Runtime.getRuntime().exec(arrayOf("su", "-c", permCmd)).waitFor() + } + + Result.success(PPSR_INTERNAL_PERSISTENT_PATH) + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun readConfigViaRoot(): SceneConfig? = withContext(Dispatchers.IO) { try { val cmd = "cat \"$PPSR_INTERNAL_PERSISTENT_PATH\"" @@ -96,13 +128,17 @@ object StorageManager { suspend fun writeConfigViaRoot(context: Context, newConfig: SceneConfig): Result = withContext(Dispatchers.IO) { try { + // Ensure directory exists + val dirCmd = "mkdir -p \"/data/data/$PPSR_PACKAGE_NAME/files/server/build/build/misc\"" + Runtime.getRuntime().exec(arrayOf("su", "-c", dirCmd)).waitFor() + // Read original content to preserve avatar, key, send_hotfix, etc. val readCmd = "cat \"$PPSR_INTERNAL_PERSISTENT_PATH\"" val readProcess = Runtime.getRuntime().exec(arrayOf("su", "-c", readCmd)) val originalContent = readProcess.inputStream.bufferedReader().use { it.readText().trim() } readProcess.waitFor() - // Save backup both inside ppSR folder and in local app cache + // Save backup both inside ppSR folder (.bak) and in local app cache if (originalContent.isNotEmpty()) { try { val bakCmd = "cp \"$PPSR_INTERNAL_PERSISTENT_PATH\" \"$PPSR_INTERNAL_BACKUP_PATH\"" diff --git a/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt b/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt index 595d721..b616caf 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt @@ -30,6 +30,8 @@ object UpdateManager { "https://api.github.com/repos/CAtlas0784/Escape-penacony/releases/latest" private const val GITHUB_RAW_SCENES_URL = + "https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/testing/scenes.json" + private const val GITHUB_RAW_SCENES_FALLBACK = "https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/main/scenes.json" suspend fun checkForAppUpdate(currentVersion: String = BuildConfig.VERSION_NAME): Result = @@ -151,12 +153,20 @@ object UpdateManager { suspend fun fetchLatestScenesJson(): Result = withContext(Dispatchers.IO) { try { - val url = URL(GITHUB_RAW_SCENES_URL) - val conn = url.openConnection() as HttpURLConnection + val primaryUrl = URL(GITHUB_RAW_SCENES_URL) + var conn = primaryUrl.openConnection() as HttpURLConnection conn.connectTimeout = 8000 conn.readTimeout = 8000 conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App") + if (conn.responseCode != 200) { + val fallbackUrl = URL(GITHUB_RAW_SCENES_FALLBACK) + conn = fallbackUrl.openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App") + } + if (conn.responseCode == 200) { val content = conn.inputStream.bufferedReader().use { it.readText() } Result.success(content) 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 917ecbf..07df60e 100644 --- a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt +++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt @@ -55,6 +55,8 @@ fun MainScreen( var isRootMode by remember { mutableStateOf(false) } var currentActiveConfig by remember { mutableStateOf(null) } var showCustomDialog by remember { mutableStateOf(false) } + var showRestoreDialog by remember { mutableStateOf(false) } + var isConnectingRoot by remember { mutableStateOf(false) } // Update States var showUpdateDialog by remember { mutableStateOf(false) } @@ -69,7 +71,7 @@ fun MainScreen( language = AppLanguage.values().firstOrNull { it.code == savedLang } ?: AppLanguage.EN planets = SceneRepository.loadScenes(context) - // Check Root Mode + // Check Root Mode silently if su permission already granted val hasRoot = StorageManager.checkRootAccess() val hasPpsr = if (hasRoot) StorageManager.checkPpsrInternalFileExists() else false isRootMode = hasRoot && hasPpsr @@ -108,7 +110,13 @@ fun MainScreen( // Filter scenes // When BETA category is selected, search globally across all planets so Starforster & Fleeting Joy always appear! val displayedScenes = remember(planetTabs, activePlanet, selectedCategory, searchQuery, language) { - val baseScenes = if (selectedCategory == FilterCategory.BETA || selectedPlanetId == PLANET_ID_ALL) { + val baseScenes = if (selectedCategory == FilterCategory.BETA) { + if (activePlanet != null && activePlanet.id != PLANET_ID_ALL && activePlanet.scenes.any { it.isBeta }) { + activePlanet.scenes + } else { + planets.flatMap { it.scenes } + } + } else if (selectedPlanetId == PLANET_ID_ALL) { planets.flatMap { it.scenes } } else { activePlanet?.scenes ?: emptyList() @@ -117,7 +125,7 @@ fun MainScreen( baseScenes.filter { scene -> val matchCategory = when (selectedCategory) { FilterCategory.ALL -> true - FilterCategory.COMBAT -> scene.combatReady + FilterCategory.COMBAT -> scene.combatReady && !scene.isBeta FilterCategory.EXPLORE -> !scene.combatReady && !scene.isBeta FilterCategory.BETA -> scene.isBeta } @@ -139,9 +147,9 @@ fun MainScreen( val list = if (selectedPlanetId == PLANET_ID_ALL) planets.flatMap { it.scenes } else activePlanet?.scenes ?: emptyList() mapOf( FilterCategory.ALL to list.size, - FilterCategory.COMBAT to list.count { it.combatReady }, + FilterCategory.COMBAT to list.count { it.combatReady && !it.isBeta }, FilterCategory.EXPLORE to list.count { !it.combatReady && !it.isBeta }, - FilterCategory.BETA to globalBetaCount + FilterCategory.BETA to if (selectedPlanetId == PLANET_ID_ALL) globalBetaCount else list.count { it.isBeta } ) } @@ -248,121 +256,188 @@ fun MainScreen( shape = RoundedCornerShape(10.dp), color = DarkSurface ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .padding(12.dp) ) { - Column(modifier = Modifier.weight(1f)) { - if (isRootMode) { - Row(verticalAlignment = Alignment.CenterVertically) { - Badge(text = "⚡ ROOT DIRECT", color = CombatGreen) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "ppSR Auto-Synced", - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - color = CombatGreen - ) - } - if (currentActiveConfig != null) { - Text( - text = "Current: Plane ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", - fontSize = 11.sp, - color = NeonCyan - ) + // Top Row: Status Badge & Target Info + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + if (isRootMode) { + Row(verticalAlignment = Alignment.CenterVertically) { + Badge(text = "⚡ ROOT DIRECT", color = CombatGreen) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "ppSR Auto-Synced", + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + color = CombatGreen + ) + } + Spacer(modifier = Modifier.height(2.dp)) + if (currentActiveConfig != null) { + Text( + text = "Current: Plane ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", + fontSize = 11.sp, + color = NeonCyan + ) + } else { + Text( + text = "/data/data/com.afn.ppSR/files/server/build/build/misc/persistent.json", + fontSize = 10.sp, + color = TextMuted, + maxLines = 1 + ) + } } else { - Text( - text = "Connected to com.afn.ppSR internal storage", - fontSize = 11.sp, - color = TextMuted - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Badge(text = "📁 SAF MODE", color = ExploreAmber) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (currentPersistentUri != null) "persistent.json (Selected)" else "Target: Not Selected", + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + color = if (currentPersistentUri != null) CombatGreen else TextMuted + ) + } + Spacer(modifier = Modifier.height(2.dp)) + if (currentActiveConfig != null) { + Text( + text = "Current Plane: ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", + fontSize = 11.sp, + color = NeonCyan + ) + } else { + Text( + text = "เลือก persistent.json หรือกดเปิด '⚡ Root Mode' เพื่อซิงก์ ppSR อัตโนมัติ", + fontSize = 11.sp, + color = TextMuted + ) + } } - } else { - Text( - text = if (currentPersistentUri != null) "Target: persistent.json" else "Target: Not Selected (SAF Mode)", - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - color = if (currentPersistentUri != null) CombatGreen else TextMuted + } + + // Restore / Revert Backup Button (Always available at top right!) + IconButton( + onClick = { showRestoreDialog = true }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.Restore, + contentDescription = "Restore Backup", + tint = StarGold, + modifier = Modifier.size(22.dp) ) - if (currentActiveConfig != null) { - Text( - text = "Current Plane: ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", - fontSize = 11.sp, - color = NeonCyan - ) - } else { - Text( - text = "Select persistent.json or grant Root for 1-tap teleport", - fontSize = 11.sp, - color = TextMuted - ) - } } } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - // Restore / Revert Backup Button - IconButton( - onClick = { - coroutineScope.launch { - val res = StorageManager.restoreBackup(context, isRootMode, currentPersistentUri) - res.onSuccess { - if (isRootMode) { - currentActiveConfig = StorageManager.readConfigViaRoot() - } else if (currentPersistentUri != null) { - currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri) - } - Toast.makeText(context, "Restored backup persistent.json successfully!", Toast.LENGTH_SHORT).show() - }.onFailure { err -> - Toast.makeText(context, "Restore failed: ${err.message}", Toast.LENGTH_SHORT).show() - } - } - }, - modifier = Modifier.size(34.dp) - ) { - Icon(Icons.Default.Restore, contentDescription = "Restore Backup", tint = StarGold, modifier = Modifier.size(20.dp)) - } + Spacer(modifier = Modifier.height(10.dp)) + // Bottom Row: Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { if (isRootMode) { // Restart ppSR Server button - OutlinedButton( + Button( onClick = { coroutineScope.launch { Toast.makeText(context, "Restarting ppSR Server...", Toast.LENGTH_SHORT).show() val res = StorageManager.restartPpsrServerViaRoot() res.onSuccess { - Toast.makeText(context, "ppSR Server restarted successfully!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "รีสตาร์ท ppSR Server สำเร็จ!", Toast.LENGTH_SHORT).show() }.onFailure { Toast.makeText(context, "Restart failed: ${it.message}", Toast.LENGTH_SHORT).show() } } }, shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), - modifier = Modifier.height(32.dp) + colors = ButtonDefaults.buttonColors( + containerColor = DarkSurfaceElevated + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(34.dp).weight(1f) ) { - Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(13.dp), tint = NeonCyan) + Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(14.dp), tint = NeonCyan) Spacer(modifier = Modifier.width(4.dp)) Text("Restart ppSR", fontSize = 11.sp, color = NeonCyan) } + + // Switch to SAF button + OutlinedButton( + onClick = { + isRootMode = false + coroutineScope.launch { + if (currentPersistentUri != null) { + currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri) + } else { + currentActiveConfig = null + } + } + Toast.makeText(context, "สลับเป็นโหมด SAF", Toast.LENGTH_SHORT).show() + }, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(34.dp) + ) { + Text("Switch to SAF", fontSize = 11.sp, color = TextMuted) + } } else { + // ⚡ Explicit Root Mode Button Button( - onClick = onPickPersistentFile, + onClick = { + coroutineScope.launch { + isConnectingRoot = true + val res = StorageManager.connectRootMode() + isConnectingRoot = false + res.onSuccess { + isRootMode = true + currentActiveConfig = StorageManager.readConfigViaRoot() + Toast.makeText(context, "⚡ เชื่อมต่อ Root Direct (ppSR) สำเร็จ!", Toast.LENGTH_SHORT).show() + }.onFailure { err -> + Toast.makeText(context, "เชื่อมต่อ Root ไม่สำเร็จ: ${err.message}", Toast.LENGTH_LONG).show() + } + } + }, shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( - containerColor = if (currentPersistentUri != null) DarkSurfaceElevated else PurplePrimary + containerColor = CombatGreen ), + enabled = !isConnectingRoot, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp) + modifier = Modifier.height(34.dp).weight(1f) ) { + Icon(Icons.Default.Bolt, contentDescription = "Root", modifier = Modifier.size(16.dp), tint = Color.Black) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = if (currentPersistentUri != null) "Change" else "Choose File", - fontSize = 11.sp + if (isConnectingRoot) "Connecting..." else "⚡ Root Mode (ppSR)", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.Black ) } + + // SAF File Chooser + OutlinedButton( + onClick = onPickPersistentFile, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = if (currentPersistentUri != null) NeonCyan else TextMuted + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(34.dp) + ) { + Icon(Icons.Default.FolderOpen, contentDescription = "Choose File", modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(if (currentPersistentUri != null) "Change File" else "Choose File", fontSize = 11.sp) + } } } } @@ -629,4 +704,64 @@ fun MainScreen( containerColor = DarkSurfaceElevated ) } + + // Restore Backup Confirmation Dialog + if (showRestoreDialog) { + AlertDialog( + onDismissRequest = { showRestoreDialog = false }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Restore, + contentDescription = null, + tint = StarGold, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "ย้อนกลับไฟล์สำรอง (Restore)", + color = TextLight, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + }, + text = { + Text( + text = "ต้องการกู้คืน persistent.json จากไฟล์สำรอง (.bak) ล่าสุดหรือไม่?\n\nระบบจะนำข้อมูลพิกัดฉากและ Calyx ก่อนการแก้ไขล่าสุดกลับมาใช้งาน", + fontSize = 13.sp, + color = TextMuted + ) + }, + confirmButton = { + Button( + onClick = { + showRestoreDialog = false + coroutineScope.launch { + val res = StorageManager.restoreBackup(context, isRootMode, currentPersistentUri) + res.onSuccess { + if (isRootMode) { + currentActiveConfig = StorageManager.readConfigViaRoot() + } else if (currentPersistentUri != null) { + currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri) + } + Toast.makeText(context, "↩️ ย้อนกลับไฟล์สำรอง (.bak) สำเร็จ!", Toast.LENGTH_SHORT).show() + }.onFailure { err -> + Toast.makeText(context, "ไม่สามารถย้อนกลับได้: ${err.message}", Toast.LENGTH_SHORT).show() + } + } + }, + colors = ButtonDefaults.buttonColors(containerColor = StarGold) + ) { + Text("ย้อนกลับไฟล์ (Restore)", color = Color.Black, fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + }, + dismissButton = { + TextButton(onClick = { showRestoreDialog = false }) { + Text("ยกเลิก (Cancel)", color = TextMuted, fontSize = 12.sp) + } + }, + containerColor = DarkSurfaceElevated + ) + } }