From 7f6583df175e623762c45d89971cdb1e89721c44 Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Wed, 7 Oct 2026 23:18:56 +0700 Subject: [PATCH] feat: add Root Direct Mode for ppSR auto-sync and fix global beta scenes filter --- .gitignore | 2 + .../escapepenacony/data/SceneRepository.kt | 5 +- .../escapepenacony/data/StorageManager.kt | 104 ++++++- .../escapepenacony/model/SceneModels.kt | 11 +- .../catlas/escapepenacony/ui/Components.kt | 13 +- .../catlas/escapepenacony/ui/MainScreen.kt | 256 +++++++++++++----- 6 files changed, 312 insertions(+), 79 deletions(-) diff --git a/.gitignore b/.gitignore index e3bdbed..b81c403 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ .cxx local.properties /app/build + +*.apk 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 c224620..cbb6b29 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt @@ -109,7 +109,10 @@ object SceneRepository { isBeta = isBeta, descEn = descEn, descTh = descTh, - descZh = descZh + descZh = descZh, + planetNameEn = pNameEn, + planetNameTh = pNameTh, + planetNameZh = pNameZh ) ) } 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 b548c0a..8795c87 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt @@ -8,13 +8,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject import java.io.File -import java.io.FileOutputStream object StorageManager { private const val PREFS_NAME = "escape_penacony_prefs" private const val KEY_PERSISTENT_URI = "persistent_uri" private const val KEY_SELECTED_LANG = "selected_language" + private const val KEY_PREFER_ROOT = "prefer_root_mode" + + const val PPSR_PACKAGE_NAME = "com.afn.ppSR" + const val PPSR_INTERNAL_PERSISTENT_PATH = + "/data/data/com.afn.ppSR/files/server/build/build/misc/persistent.json" fun savePersistentUri(context: Context, uri: Uri) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -45,6 +49,100 @@ object StorageManager { .getString(KEY_SELECTED_LANG, "en") ?: "en" } + // ------------------------------------------------------------------------- + // Root Direct Access Engine (su) + // ------------------------------------------------------------------------- + + suspend fun checkRootAccess(): Boolean = withContext(Dispatchers.IO) { + try { + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "id")) + val exit = process.waitFor() + exit == 0 + } catch (e: Exception) { + false + } + } + + suspend fun checkPpsrInternalFileExists(): Boolean = withContext(Dispatchers.IO) { + try { + val cmd = "[ -f \"$PPSR_INTERNAL_PERSISTENT_PATH\" ] && echo 'EXISTS'" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd)) + val output = process.inputStream.bufferedReader().use { it.readText().trim() } + process.waitFor() + output == "EXISTS" + } catch (e: Exception) { + false + } + } + + suspend fun readConfigViaRoot(): SceneConfig? = withContext(Dispatchers.IO) { + try { + val cmd = "cat \"$PPSR_INTERNAL_PERSISTENT_PATH\"" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd)) + val jsonContent = process.inputStream.bufferedReader().use { it.readText().trim() } + val exit = process.waitFor() + if (exit != 0 || jsonContent.isEmpty()) return@withContext null + + val root = JSONObject(jsonContent) + val sceneObj = root.optJSONObject("scene") ?: return@withContext null + SceneConfig.fromJsonObject(sceneObj) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + suspend fun writeConfigViaRoot(context: Context, newConfig: SceneConfig): Result = + withContext(Dispatchers.IO) { + try { + // 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() + + val updatedJson = PersistentHelper.updateJsonString( + if (originalContent.isNotEmpty()) originalContent else "{}", + newConfig + ) + + // Write to cache file first + val cacheFile = File(context.cacheDir, "persistent_root_sync.json") + cacheFile.writeText(updatedJson, Charsets.UTF_8) + + // Copy via su into ppSR private storage and fix permissions + val copyCmd = "cp \"${cacheFile.absolutePath}\" \"$PPSR_INTERNAL_PERSISTENT_PATH\" && chmod 666 \"$PPSR_INTERNAL_PERSISTENT_PATH\"" + val copyProcess = Runtime.getRuntime().exec(arrayOf("su", "-c", copyCmd)) + val copyExit = copyProcess.waitFor() + + if (copyExit == 0) { + Result.success(Unit) + } else { + val errorText = copyProcess.errorStream.bufferedReader().use { it.readText().trim() } + Result.failure(Exception("Root copy failed ($copyExit): $errorText")) + } + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } + } + + suspend fun restartPpsrServerViaRoot(): Result = withContext(Dispatchers.IO) { + try { + // Force-stop then relaunch MainActivity + val restartCmd = "am force-stop $PPSR_PACKAGE_NAME && sleep 0.5 && am start -n $PPSR_PACKAGE_NAME/.MainActivity" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", restartCmd)) + val exit = process.waitFor() + if (exit == 0) Result.success(Unit) else Result.failure(Exception("Restart exited with $exit")) + } catch (e: Exception) { + Result.failure(e) + } + } + + // ------------------------------------------------------------------------- + // SAF / Shared Storage Access Engine (Non-Root fallback) + // ------------------------------------------------------------------------- + suspend fun readCurrentConfig(context: Context, uri: Uri): SceneConfig? = withContext(Dispatchers.IO) { try { val content = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } @@ -60,19 +158,15 @@ object StorageManager { suspend fun applySceneConfig(context: Context, uri: Uri, newConfig: SceneConfig): Result = withContext(Dispatchers.IO) { try { - // Read original content val originalContent = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: "{}" - // Backup copy in app cache try { val backupFile = File(context.cacheDir, "persistent_backup.json") backupFile.writeText(originalContent) } catch (_: Exception) {} - // Generate updated JSON val updatedJson = PersistentHelper.updateJsonString(originalContent, newConfig) - // Overwrite target file via SAF context.contentResolver.openOutputStream(uri, "wt")?.use { output -> output.write(updatedJson.toByteArray(Charsets.UTF_8)) output.flush() diff --git a/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt b/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt index 025bb3a..c3168aa 100644 --- a/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt +++ b/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt @@ -34,7 +34,10 @@ data class SceneItem( val isBeta: Boolean, val descEn: String, val descTh: String, - val descZh: String + val descZh: String, + val planetNameEn: String = "", + val planetNameTh: String = "", + val planetNameZh: String = "" ) { fun localizedName(lang: AppLanguage): String = when (lang) { AppLanguage.TH -> nameTh.ifEmpty { nameEn } @@ -47,6 +50,12 @@ data class SceneItem( AppLanguage.ZH -> descZh.ifEmpty { descEn } AppLanguage.EN -> descEn } + + fun localizedPlanetName(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> planetNameTh.ifEmpty { planetNameEn } + AppLanguage.ZH -> planetNameZh.ifEmpty { planetNameEn } + AppLanguage.EN -> planetNameEn + } } data class PlanetItem( diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt b/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt index 9f8d3ad..123c403 100644 --- a/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt +++ b/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt @@ -134,7 +134,18 @@ fun SceneCard( } } - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(4.dp)) + + val planetName = scene.localizedPlanetName(language) + if (planetName.isNotEmpty()) { + Text( + text = "🌐 $planetName", + fontSize = 11.sp, + color = StarGold, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(2.dp)) + } // Coords & Plane ID Text( 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 114157a..8912ed4 100644 --- a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt +++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt @@ -35,6 +35,8 @@ import com.catlas.escapepenacony.model.* import com.catlas.escapepenacony.ui.theme.* import kotlinx.coroutines.launch +private const val PLANET_ID_ALL = "ALL_WORLDS" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -45,11 +47,12 @@ fun MainScreen( val coroutineScope = rememberCoroutineScope() var planets by remember { mutableStateOf>(emptyList()) } - var selectedPlanetId by remember { mutableStateOf(null) } + var selectedPlanetId by remember { mutableStateOf(PLANET_ID_ALL) } var selectedCategory by remember { mutableStateOf(FilterCategory.ALL) } var searchQuery by remember { mutableStateOf("") } var language by remember { mutableStateOf(AppLanguage.EN) } + var isRootMode by remember { mutableStateOf(false) } var currentActiveConfig by remember { mutableStateOf(null) } var showCustomDialog by remember { mutableStateOf(false) } @@ -60,29 +63,57 @@ fun MainScreen( var downloadProgress by remember { mutableStateOf(0) } var isCheckingUpdate by remember { mutableStateOf(false) } - // Load initial data + // Load initial data and check Root LaunchedEffect(Unit) { val savedLang = StorageManager.getSavedLanguage(context) language = AppLanguage.values().firstOrNull { it.code == savedLang } ?: AppLanguage.EN planets = SceneRepository.loadScenes(context) - if (planets.isNotEmpty()) { - selectedPlanetId = planets.first().id + + // Check Root Mode + val hasRoot = StorageManager.checkRootAccess() + val hasPpsr = if (hasRoot) StorageManager.checkPpsrInternalFileExists() else false + isRootMode = hasRoot && hasPpsr + if (isRootMode) { + currentActiveConfig = StorageManager.readConfigViaRoot() } } - // Refresh active config when persistent URI changes - LaunchedEffect(currentPersistentUri) { - if (currentPersistentUri != null) { + // Refresh active config if in SAF Mode + LaunchedEffect(currentPersistentUri, isRootMode) { + if (!isRootMode && currentPersistentUri != null) { currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri) - } else { - currentActiveConfig = null } } + // Prepare list of planets including "All Worlds" tab + val planetTabs = remember(planets, language) { + if (planets.isEmpty()) emptyList() else { + val allScenes = planets.flatMap { it.scenes } + listOf( + PlanetItem( + id = PLANET_ID_ALL, + nameEn = "All Worlds", + nameTh = "ทุกโลก (All)", + nameZh = "所有世界", + scenes = allScenes + ) + ) + planets + } + } + + val activePlanet = remember(planetTabs, selectedPlanetId) { + planetTabs.firstOrNull { it.id == selectedPlanetId } ?: planetTabs.firstOrNull() + } + // Filter scenes - val activePlanet = planets.firstOrNull { it.id == selectedPlanetId } - val displayedScenes = remember(activePlanet, selectedCategory, searchQuery, language) { - val baseScenes = activePlanet?.scenes ?: emptyList() + // 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) { + planets.flatMap { it.scenes } + } else { + activePlanet?.scenes ?: emptyList() + } + baseScenes.filter { scene -> val matchCategory = when (selectedCategory) { FilterCategory.ALL -> true @@ -101,13 +132,16 @@ fun MainScreen( } // Category Counts - val counts = remember(activePlanet) { - val list = activePlanet?.scenes ?: emptyList() + val globalBetaCount = remember(planets) { + planets.flatMap { it.scenes }.count { it.isBeta } + } + val counts = remember(activePlanet, selectedPlanetId, globalBetaCount) { + 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.EXPLORE to list.count { !it.combatReady && !it.isBeta }, - FilterCategory.BETA to list.count { it.isBeta } + FilterCategory.BETA to globalBetaCount ) } @@ -206,7 +240,7 @@ fun MainScreen( .fillMaxSize() .padding(paddingValues) ) { - // Persistent Target File Section + // Persistent Target File Section (Root Mode vs SAF Mode) Surface( modifier = Modifier .fillMaxWidth() @@ -222,40 +256,90 @@ fun MainScreen( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = if (currentPersistentUri != null) "Target: persistent.json" else "Target: Not Selected", - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - color = if (currentPersistentUri != null) CombatGreen else TextMuted - ) - if (currentActiveConfig != null) { - Text( - text = "Current Plane: ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", - fontSize = 11.sp, - color = NeonCyan - ) + 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 + ) + } else { + Text( + text = "Connected to com.afn.ppSR internal storage", + fontSize = 11.sp, + color = TextMuted + ) + } } else { Text( - text = "Select file to enable one-tap teleport", - fontSize = 11.sp, - color = TextMuted + 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 ) + 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 + ) + } } } - Button( - onClick = onPickPersistentFile, - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (currentPersistentUri != null) DarkSurfaceElevated else PurplePrimary - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp) - ) { - Text( - text = if (currentPersistentUri != null) "Change" else "Choose File", - fontSize = 11.sp - ) + if (isRootMode) { + // Restart ppSR Server button + OutlinedButton( + 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() + }.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) + ) { + Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(13.dp), tint = NeonCyan) + Spacer(modifier = Modifier.width(4.dp)) + Text("Restart ppSR", fontSize = 11.sp, color = NeonCyan) + } + } else { + Button( + onClick = onPickPersistentFile, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (currentPersistentUri != null) DarkSurfaceElevated else PurplePrimary + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp) + ) { + Text( + text = if (currentPersistentUri != null) "Change" else "Choose File", + fontSize = 11.sp + ) + } } } } @@ -264,7 +348,7 @@ fun MainScreen( OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, - placeholder = { Text("Search scenes (e.g. Penacony, 20461, Parlor Car)...", fontSize = 12.sp) }, + placeholder = { Text("Search scenes (e.g. Starforster, Fleeting, 20571)...", fontSize = 12.sp) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted) }, trailingIcon = { if (searchQuery.isNotEmpty()) { @@ -286,8 +370,8 @@ fun MainScreen( ) ) - // Planet Horizontal Tabs - if (planets.isNotEmpty()) { + // Planet Horizontal Tabs (Including "All Worlds") + if (planetTabs.isNotEmpty()) { LazyRow( modifier = Modifier .fillMaxWidth() @@ -295,7 +379,7 @@ fun MainScreen( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(planets) { planet -> + items(planetTabs) { planet -> val isSelected = planet.id == selectedPlanetId Surface( shape = RoundedCornerShape(16.dp), @@ -350,24 +434,43 @@ fun MainScreen( scene = scene, language = language, onTeleportClick = { targetScene -> - if (currentPersistentUri == null) { - Toast.makeText(context, "Please choose persistent.json first!", Toast.LENGTH_SHORT).show() - onPickPersistentFile() - return@SceneCard - } - val cfg = SceneConfig.fromScene(targetScene) - coroutineScope.launch { - val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg) - res.onSuccess { - currentActiveConfig = cfg - Toast.makeText( - context, - "Teleported to ${targetScene.localizedName(language)} (Plane ${targetScene.planeId})!", - Toast.LENGTH_SHORT - ).show() - }.onFailure { - Toast.makeText(context, "Teleport failed: ${it.message}", Toast.LENGTH_LONG).show() + + if (isRootMode) { + // ⚡ Direct Root Execution to com.afn.ppSR + coroutineScope.launch { + val res = StorageManager.writeConfigViaRoot(context, cfg) + res.onSuccess { + currentActiveConfig = cfg + Toast.makeText( + context, + "⚡ [Root Direct] Teleported to ${targetScene.localizedName(language)} (Plane ${targetScene.planeId})!", + Toast.LENGTH_LONG + ).show() + }.onFailure { err -> + Toast.makeText(context, "Root write failed: ${err.message}", Toast.LENGTH_LONG).show() + } + } + } else { + // Fallback SAF Mode + if (currentPersistentUri == null) { + Toast.makeText(context, "Please choose persistent.json first (or grant Root access)!", Toast.LENGTH_SHORT).show() + onPickPersistentFile() + return@SceneCard + } + + coroutineScope.launch { + val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg) + res.onSuccess { + currentActiveConfig = cfg + Toast.makeText( + context, + "Teleported to ${targetScene.localizedName(language)} (Plane ${targetScene.planeId})!", + Toast.LENGTH_SHORT + ).show() + }.onFailure { + Toast.makeText(context, "Teleport failed: ${it.message}", Toast.LENGTH_LONG).show() + } } } }, @@ -401,12 +504,18 @@ fun MainScreen( calyxZ = z + 1800, mapLayer = layer ) - if (currentPersistentUri == null) { - val jsonStr = StorageManager.exportTemplateJson(context, cfg) - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Custom Teleport JSON", jsonStr)) - Toast.makeText(context, "JSON copied! Please choose persistent.json to apply directly.", Toast.LENGTH_LONG).show() - } else { + + if (isRootMode) { + coroutineScope.launch { + val res = StorageManager.writeConfigViaRoot(context, cfg) + res.onSuccess { + currentActiveConfig = cfg + Toast.makeText(context, "⚡ Custom coordinates applied via Root!", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(context, "Root write failed: ${it.message}", Toast.LENGTH_LONG).show() + } + } + } else if (currentPersistentUri != null) { coroutineScope.launch { val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg) res.onSuccess { @@ -416,6 +525,11 @@ fun MainScreen( Toast.makeText(context, "Failed: ${it.message}", Toast.LENGTH_LONG).show() } } + } else { + val jsonStr = StorageManager.exportTemplateJson(context, cfg) + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Custom Teleport JSON", jsonStr)) + Toast.makeText(context, "JSON copied! Please choose persistent.json or enable Root to apply directly.", Toast.LENGTH_LONG).show() } } )