feat: add Root Direct Mode for ppSR auto-sync and fix global beta scenes filter

This commit is contained in:
CAtlas0784 committed 2026-10-07 23:18:56 +07:00
1 parent 164219f7dd
commit 7f6583df17
6 files changed
+312 -79

No files matched your search

+2
View File
@@ -9,3 +9,5 @@
.cxx
local.properties
/app/build
*.apk
@@ -109,7 +109,10 @@ object SceneRepository {
isBeta = isBeta,
descEn = descEn,
descTh = descTh,
descZh = descZh
descZh = descZh,
planetNameEn = pNameEn,
planetNameTh = pNameTh,
planetNameZh = pNameZh
)
)
}
@@ -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<Unit> =
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<Unit> = 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<Unit> = 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()
@@ -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(
@@ -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(
@@ -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<List<PlanetItem>>(emptyList()) }
var selectedPlanetId by remember { mutableStateOf<String?>(null) }
var selectedPlanetId by remember { mutableStateOf<String>(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<SceneConfig?>(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()
}
}
)