Port Android server runtime to GaCy Go

This commit is contained in:
CAtlas0784 committed 2026-11-02 02:39:37 +07:00
1 parent 92a4b4f545
commit 3c1186c781
20 files changed
+830 -798

No files matched your search

@@ -0,0 +1,84 @@
package com.gacy.android
import android.content.Context
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object CrashTracker {
private const val PREF_NAME = "gacy_crash_tracker"
private const val KEY_LAST_CRASH = "last_crash"
private const val KEY_BREADCRUMBS = "breadcrumbs"
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
fun init(context: Context) {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
recordCrash(context, thread, throwable)
} catch (_: Exception) {}
defaultHandler?.uncaughtException(thread, throwable)
}
}
fun recordCrash(context: Context, thread: Thread, throwable: Throwable) {
try {
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
val stackTrace = sw.toString()
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
val breadcrumbs = prefs.getString(KEY_BREADCRUMBS, "None") ?: "None"
val fullReport = "Thread: ${thread.name}\nTimestamp: ${timeFormat.format(Date())}\n\nRecent Actions:\n$breadcrumbs\n\nStacktrace:\n$stackTrace"
prefs.edit().putString(KEY_LAST_CRASH, fullReport).commit()
// Also write directly to disk file for redundancy
val crashFile = File(context.filesDir, "last_crash.txt")
crashFile.writeText(fullReport)
} catch (_: Exception) {}
}
fun breadcrumb(context: Context, text: String) {
try {
val timestamp = timeFormat.format(Date())
val entry = "[$timestamp] $text"
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
val current = prefs.getString(KEY_BREADCRUMBS, "") ?: ""
val updated = if (current.isBlank()) entry else "$current\n$entry"
val lines = updated.lines().takeLast(15).joinToString("\n")
prefs.edit().putString(KEY_BREADCRUMBS, lines).apply()
val breadcrumbFile = File(context.filesDir, "breadcrumbs.txt")
breadcrumbFile.appendText("$entry\n")
} catch (_: Exception) {}
}
fun getLastCrash(context: Context): String? {
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
val fromPrefs = prefs.getString(KEY_LAST_CRASH, null)
if (!fromPrefs.isNullOrBlank()) return fromPrefs
val crashFile = File(context.filesDir, "last_crash.txt")
if (crashFile.exists() && crashFile.length() > 0) {
return crashFile.readText()
}
return null
}
fun clearLastCrash(context: Context) {
try {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit()
.remove(KEY_LAST_CRASH)
.apply()
val crashFile = File(context.filesDir, "last_crash.txt")
if (crashFile.exists()) crashFile.delete()
} catch (_: Exception) {}
}
}
@@ -4,8 +4,6 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
class GaCyApplication : Application() {
@@ -18,17 +16,11 @@ class GaCyApplication : Application() {
override fun onCreate() {
super.onCreate()
CrashTracker.init(this)
CrashTracker.breadcrumb(this, "GaCyApplication: onCreate")
instance = this
createNotificationChannel()
// Initialize embedded Python runtime
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
} catch (t: Throwable) {
t.printStackTrace()
}
}
private fun createNotificationChannel() {
@@ -1,14 +1,35 @@
package com.gacy.android
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.gacy.android.ui.screens.MainScreen
import com.gacy.android.ui.theme.GaCyTheme
@@ -16,12 +37,83 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CrashTracker.breadcrumb(this, "MainActivity: onCreate")
// ซ่อน Status Bar
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) // ใช้ .systemBars() ถ้าต้องการซ่อน Navigation Bar ด้านล่างด้วย
enableEdgeToEdge()
requestAppPermissions()
setContent {
GaCyTheme {
var crashReport by remember { mutableStateOf(CrashTracker.getLastCrash(this@MainActivity)) }
MainScreen()
if (crashReport != null) {
AlertDialog(
onDismissRequest = {
CrashTracker.clearLastCrash(this@MainActivity)
crashReport = null
},
title = {
Text(
"⚠️ GaCy Crash Report",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
},
text = {
Column {
Text(
"A fatal error occurred in the previous session:",
fontSize = 13.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
SelectionContainer {
Text(
text = crashReport ?: "",
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.verticalScroll(rememberScrollState())
.background(Color(0xFF1E1E1E), RoundedCornerShape(8.dp))
.padding(8.dp),
color = Color(0xFFFF8A80)
)
}
}
},
confirmButton = {
Button(
onClick = {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = ClipData.newPlainText("GaCy Crash", crashReport)
clipboard?.setPrimaryClip(clip)
Toast.makeText(this@MainActivity, "Crash report copied!", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy Log")
}
},
dismissButton = {
TextButton(
onClick = {
CrashTracker.clearLastCrash(this@MainActivity)
crashReport = null
}
) {
Text("Dismiss")
}
}
)
}
}
}
}
@@ -9,40 +9,42 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
class GaCyApiRepository {
internal fun parseUnstuckResponse(body: String): GmCommandResult {
return try {
val result = JSONObject(body)
val requestId = result.opt("request_id") as? String
if (result.optString("state") == "queued" && !requestId.isNullOrBlank()) {
GmCommandResult(true, "Unstuck request queued; waiting for the game client to process it.")
} else {
GmCommandResult(false, "Server did not confirm an unstuck request.")
}
} catch (_: Exception) {
GmCommandResult(false, "Server returned an invalid unstuck response.")
}
}
private val client = OkHttpClient.Builder()
class GaCyApiRepository(private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build()
.build()) {
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val url = "${config.dispatchUrl}/status"
val url = "${config.dispatchUrl}/api/status"
try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
val duration = System.currentTimeMillis() - start
Pair(response.isSuccessful, duration)
val payload = response.body?.string().orEmpty()
Pair(response.isSuccessful && JSONObject(payload).optInt("status") == 200, duration)
}
} catch (e: Exception) {
// Try query_gateway fallback
try {
val gwUrl = "${config.dispatchUrl}/query_gateway"
val request = Request.Builder().url(gwUrl).get().build()
client.newCall(request).execute().use { response ->
val duration = System.currentTimeMillis() - start
Pair(response.isSuccessful || response.code == 400 || response.code == 200, duration)
}
} catch (ex: Exception) {
Pair(false, -1L)
}
Pair(false, -1L)
}
}
@@ -53,19 +55,19 @@ class GaCyApiRepository {
val request = Request.Builder().url(url).post(body).build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
GmCommandResult(true, "Unstuck packet sent to game client successfully.")
parseUnstuckResponse(response.body?.string().orEmpty())
} else {
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
}
}
} catch (e: IOException) {
} catch (e: Exception) {
GmCommandResult(false, "Network error: ${e.message}")
}
}
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
val cleanCmd = command.trim()
val url = "${config.dispatchUrl}/api/gm/command"
val url = "${config.dispatchUrl}/api/command"
try {
val payload = JSONObject().apply {
put("command", cleanCmd)
@@ -75,7 +77,11 @@ class GaCyApiRepository {
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
GmCommandResult(true, responseBody.ifEmpty { "Command executed successfully: $cleanCmd" })
val result = JSONObject(responseBody)
val success = result.optBoolean("success", false)
GmCommandResult(success, result.optString("output").ifBlank {
result.optString("error", "Server returned an invalid command result")
})
} else {
GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)")
}
@@ -0,0 +1,86 @@
package com.gacy.android.data
import android.content.Context
import org.json.JSONObject
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.zip.ZipInputStream
/** Runtime files for the Go server. Never replace the server's mutable data. */
class GoRuntimeRepository(private val context: Context) {
val directory = File(context.filesDir, "gacy-go")
fun prepare() = GoRuntimeInstaller(directory) { context.assets.open("gacy-go-data.zip") }.prepare()
}
/** Install resources without replacing existing saves or extracting concurrently. */
internal class GoRuntimeInstaller(private val directory: File, private val openArchive: () -> InputStream) {
companion object {
private val installLock = Any()
private val required = listOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json", "res.json", "teleports.json", "resources/MapEntrance.json")
}
private fun replaceFile(temp: File, target: File) {
try {
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}
fun prepare() = synchronized(installLock) {
val marker = File(directory, ".asset-version")
val digest = MessageDigest.getInstance("SHA-256")
openArchive().use { input ->
val buffer = ByteArray(8192)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
// Bump the installer revision when existing archives need a new merge.
val version = "2:" + digest.digest().joinToString("") { "%02x".format(it) }
if (marker.isFile && marker.readText() == version && required.all { File(directory, it).isFile }) return@synchronized
directory.mkdirs()
val root = directory.canonicalFile
val mutable = setOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json")
ZipInputStream(openArchive().buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
val target = File(directory, entry.name).canonicalFile
require(target.path.startsWith(root.path + File.separator)) { "Invalid runtime asset path" }
if (entry.isDirectory) target.mkdirs()
else if (entry.name in setOf("hotfix.json", "versions.json") && target.exists()) {
// Add new bundled versions without replacing operator overrides.
val existing = JSONObject(target.readText())
val bundled = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
val keys = bundled.keys()
var changed = false
while (keys.hasNext()) {
val key = keys.next()
if (!existing.has(key)) { existing.put(key, bundled.get(key)); changed = true }
}
if (changed) {
val temp = File(target.parentFile, target.name + ".installing")
temp.writeText(existing.toString(2))
replaceFile(temp, target)
}
}
else if (entry.name !in mutable || !target.exists()) {
target.parentFile?.mkdirs()
val temp = File(target.parentFile, target.name + ".installing")
temp.outputStream().use { zip.copyTo(it) }
replaceFile(temp, target)
}
zip.closeEntry()
}
}
check(required.all { File(directory, it).isFile }) { "Go runtime archive is incomplete" }
marker.writeText(version)
}
}
@@ -1,5 +1,7 @@
package com.gacy.android.model
import java.net.URI
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
@@ -22,7 +24,19 @@ data class ServerConfig(
val sendHotfix: Boolean = true
) {
val dispatchUrl: String
get() = "http://$remoteHost:$dispatchPort"
get() = "http://${if (executionMode == ServerExecutionMode.LOCAL_EMBEDDED) "127.0.0.1" else remoteHost}:$dispatchPort"
/** Parse the host and ports the same way they will be used by the server. */
fun withNetworkInput(mode: ServerExecutionMode, host: String, sdkPort: String, gamePort: String): ServerConfig? {
val cleanHost = host.trim()
val sdk = sdkPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
val game = gamePort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
val address = try { URI("http://$cleanHost") } catch (_: Exception) { return null }
if (address.host.isNullOrBlank() || address.port != -1 || address.userInfo != null ||
!address.rawPath.isNullOrEmpty() || address.rawQuery != null || address.rawFragment != null ||
cleanHost.contains(':') || cleanHost == "0.0.0.0") return null
return copy(executionMode = mode, remoteHost = cleanHost, dispatchPort = sdk, gameserverPort = game)
}
}
data class GmCommandResult(
@@ -10,7 +10,9 @@ import java.util.Locale
object LogBuffer {
private const val MAX_LOG_LINES = 500
private const val MAX_DIAGNOSTIC_LINES = 100
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val diagnostics = ArrayDeque<String>()
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs.asStateFlow()
@@ -19,6 +21,10 @@ object LogBuffer {
fun append(message: String) {
val timestamp = timeFormat.format(Date())
val formatted = "[$timestamp] $message"
if (isDiagnostic(message)) {
diagnostics.addLast(formatted)
if (diagnostics.size > MAX_DIAGNOSTIC_LINES) diagnostics.removeFirst()
}
val current = _logs.value.toMutableList()
current.add(formatted)
if (current.size > MAX_LOG_LINES) {
@@ -30,9 +36,18 @@ object LogBuffer {
@Synchronized
fun clear() {
_logs.value = emptyList()
diagnostics.clear()
}
@Synchronized
fun getAllText(): String {
return _logs.value.joinToString("\n")
val recent = _logs.value
val olderDiagnostics = diagnostics.filterNot { it in recent }
return (olderDiagnostics + recent).joinToString("\n")
}
private fun isDiagnostic(message: String): Boolean =
"[gateway]" in message || "[DISPATCH]" in message ||
"[KCP] handshake rejected:" in message || "[KCP] packet rejected:" in message ||
"New connection from addr:" in message || "[Server error]" in message
}
@@ -11,334 +11,167 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import com.gacy.android.GaCyApplication
import com.gacy.android.MainActivity
import com.gacy.android.R
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.GaCyApiRepository
import com.gacy.android.data.GoRuntimeRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.data.UpdateRepository
import com.gacy.android.model.ServerConfig
import com.gacy.android.model.ServerExecutionMode
import com.gacy.android.model.ServerStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.net.InetSocketAddress
import java.net.Socket
class ServerLogReceiver {
fun onServerLog(msg: String) {
LogBuffer.append(msg)
}
}
import java.util.concurrent.TimeUnit
class ServerService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var serverThread: Thread? = null
private var probeJob: Job? = null
private var executionJob: Job? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private val processes = mutableListOf<Process>()
private val lock = Any()
@Volatile private var generation = 0
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
_serverStatus.value = ServerStatus.STOPPED
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_START
when (action) {
when (intent?.action ?: ACTION_START) {
ACTION_START -> startServer()
ACTION_STOP -> stopServer()
ACTION_RESTART -> restartServer()
ACTION_RESTART -> { stopServer(false); startServer() }
}
return START_NOT_STICKY
}
private fun startServer() {
if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) {
return
}
try {
val prefs = PreferencesRepository(this)
val config = prefs.getServerConfig()
_serverStatus.value = ServerStatus.STARTING
startForegroundCompat("GaCy Server is starting...")
LogBuffer.append("--- GaCy Server Starting ---")
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
startLocalServerExecution(config.dispatchPort)
} else {
startRemoteMonitor(config.remoteHost, config.dispatchPort)
}
} catch (t: Throwable) {
LogBuffer.append("[Start Error] ${t.localizedMessage ?: t.message}")
t.printStackTrace()
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
}
}
private fun startLocalServerExecution(dispatchPort: Int) {
executionJob?.cancel()
executionJob = serviceScope.launch {
if (_serverStatus.value == ServerStatus.STARTING || _serverStatus.value == ServerStatus.RUNNING) return
val run = ++generation
val config = PreferencesRepository(this).getServerConfig()
_serverStatus.value = ServerStatus.STARTING
startForegroundCompat("Starting GaCy Go server")
job = scope.launch {
try {
val updateRepo = UpdateRepository(this@ServerService)
if (!updateRepo.isServerInstalled()) {
LogBuffer.append("[Setup] Installing GaCy embedded server binaries from assets...")
val success = updateRepo.ensureServerInstalled()
if (!success) {
LogBuffer.append("[Error] Failed to install server binaries from assets.")
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
return@launch
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
val runtime = GoRuntimeRepository(applicationContext)
LogBuffer.append("[Setup] Preparing GaCy Go runtime")
runtime.prepare()
ensureActive()
launchProcess("gameserver", runtime.directory, config, run)
launchProcess("sdkserver", runtime.directory, config, run)
}
val api = GaCyApiRepository()
var ready = false
repeat(40) {
if (!ready) {
ensureActive()
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) checkProcesses()
ready = api.checkServerHealth(config).first
if (!ready) delay(500)
}
LogBuffer.append("[Setup] Server binaries installed successfully.")
}
val ppsrFile = File(filesDir, "server/build/build/ppsr.pyc")
if (!ppsrFile.exists()) {
LogBuffer.append("[Error] Server bytecode missing at: ${ppsrFile.absolutePath}")
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
return@launch
}
try {
val persistentRepo = PersistentRepository(this@ServerService)
persistentRepo.ensureValidPersistentJsonSync()
} catch (e: Throwable) {
LogBuffer.append("[Warning] Persistent validation: ${e.message}")
}
LogBuffer.append("[System] Launching embedded GaCy Python server...")
// Background TCP port readiness probe
probeJob?.cancel()
probeJob = serviceScope.launch {
var attempts = 0
while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 35) {
if (!ready) error("SDK server did not respond")
if (run != generation) return@launch
_serverStatus.value = ServerStatus.RUNNING
updateNotification("GaCy Go server is running")
LogBuffer.append("[System] SDK server is ready at ${config.dispatchUrl}")
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
while (isActive && run == generation) {
delay(1000)
attempts++
if (isPortOpen("127.0.0.1", dispatchPort)) {
_serverStatus.value = ServerStatus.RUNNING
updateNotification("GaCy Server is RUNNING on :$dispatchPort")
LogBuffer.append("[System] GaCy Dispatch server is ready on 127.0.0.1:$dispatchPort")
break
}
checkProcesses()
}
} else {
var failures = 0
while (isActive && run == generation) {
delay(5000)
val healthy = api.checkServerHealth(config).first
ensureActive()
failures = if (healthy) 0 else failures + 1
if (failures >= 3) error("Remote SDK server stopped responding")
}
}
serverThread = Thread({
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(applicationContext))
}
val py = Python.getInstance()
val runPpsr = py.getModule("run_ppsr")
val logReceiver = ServerLogReceiver()
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
} catch (t: Throwable) {
LogBuffer.append("[Python Error] ${t.localizedMessage ?: t.message}")
t.printStackTrace()
} finally {
probeJob?.cancel()
_serverStatus.value = ServerStatus.STOPPED
LogBuffer.append("[System] Server stopped.")
stopForegroundCompat()
stopSelf()
}
}, "GaCy-PythonServer").apply {
start()
} catch (_: CancellationException) {
} catch (e: Exception) {
if (run == generation) {
LogBuffer.append("[Server error] ${e.message}")
terminateProcesses()
_serverStatus.value = ServerStatus.ERROR
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
} catch (t: Throwable) {
LogBuffer.append("[Execution Error] ${t.localizedMessage ?: t.message}")
t.printStackTrace()
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
}
}
}
private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) {
executionJob?.cancel()
executionJob = serviceScope.launch {
LogBuffer.append("[Remote] Connecting to remote GaCy host at $remoteHost:$dispatchPort...")
delay(600)
_serverStatus.value = ServerStatus.RUNNING
updateNotification("Monitoring Remote GaCy Host at $remoteHost")
LogBuffer.append("[Remote] Connected to remote GaCy server monitor.")
private fun checkProcesses() {
val failed = synchronized(lock) { processes.firstOrNull { !it.isAlive } }
if (failed != null) error("A Go server process exited with code ${failed.exitValue()}; see the log")
}
private fun launchProcess(name: String, directory: File, config: ServerConfig, run: Int) {
val executable = File(applicationInfo.nativeLibraryDir, "libgacy_${name}.so")
check(executable.isFile) { "Missing Go runtime: ${executable.name}" }
val builder = ProcessBuilder(executable.absolutePath).directory(directory).redirectErrorStream(true)
builder.environment().apply {
put("GAC_SDK_PORT", config.dispatchPort.toString())
put("GAC_GAME_PORT", config.gameserverPort.toString())
put("GAC_PUBLIC_GAME_HOST", config.remoteHost)
put("GAC_PUBLIC_SDK_URL", "http://${config.remoteHost}:${config.dispatchPort}")
}
val process = builder.start()
synchronized(lock) {
if (run != generation) { process.destroy(); throw CancellationException() }
processes.add(process)
}
scope.launch {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { LogBuffer.append("[$name] $it") }
}
} catch (e: java.io.IOException) {
if (process.isAlive && run == generation) LogBuffer.append("[$name] Log stream error: ${e.message}")
}
}
}
private fun stopServer() {
LogBuffer.append("--- GaCy Server Stopping ---")
private fun terminateProcesses() {
val old = synchronized(lock) { processes.toList().also { processes.clear() } }
old.forEach { it.destroy() }
old.forEach { if (!it.waitFor(2, TimeUnit.SECONDS)) it.destroyForcibly() }
}
private fun stopServer(stopService: Boolean = true) {
++generation
job?.cancel()
job = null
_serverStatus.value = ServerStatus.STOPPING
updateNotification("Stopping GaCy Server...")
probeJob?.cancel()
probeJob = null
executionJob?.cancel()
executionJob = null
serviceScope.launch {
try {
if (Python.isStarted()) {
val py = Python.getInstance()
val runPpsr = py.getModule("run_ppsr")
runPpsr.callAttr("kill_server_process")
}
} catch (e: Throwable) {
LogBuffer.append("[Stop Error] ${e.localizedMessage ?: e.message}")
}
delay(1200)
serverThread?.interrupt()
serverThread = null
_serverStatus.value = ServerStatus.STOPPED
stopForegroundCompat()
stopSelf()
}
}
private fun restartServer() {
LogBuffer.append("Restarting GaCy Server...")
stopServer()
serviceScope.launch {
delay(1500)
startServer()
}
}
private fun isPortOpen(host: String, port: Int): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 400)
true
}
} catch (_: Throwable) {
false
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
GaCyApplication.CHANNEL_ID,
GaCyApplication.CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Background service for GaCy Game Server & Proxy"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
}
private fun createNotification(contentText: String): Notification {
createNotificationChannel()
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stopIntent = Intent(this, ServerService::class.java).apply {
action = ACTION_STOP
}
val stopPendingIntent = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
.setContentTitle("GaCy Android")
.setContentText(contentText)
.setSmallIcon(R.drawable.app_icon)
.setOngoing(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
.build()
}
private fun startForegroundCompat(text: String) {
try {
createNotificationChannel()
val notification = createNotification(text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
// API 26..33
startForeground(NOTIFICATION_ID, notification)
}
} catch (e: Throwable) {
try {
val notification = createNotification(text)
startForeground(NOTIFICATION_ID, notification)
} catch (e2: Throwable) {
LogBuffer.append("[Foreground Warning] ${e2.message}")
}
}
}
private fun updateNotification(contentText: String) {
try {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.notify(NOTIFICATION_ID, createNotification(contentText))
} catch (_: Throwable) {}
}
private fun stopForegroundCompat() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
} catch (_: Throwable) {}
}
override fun onDestroy() {
probeJob?.cancel()
serverThread?.interrupt()
executionJob?.cancel()
super.onDestroy()
terminateProcesses()
_serverStatus.value = ServerStatus.STOPPED
LogBuffer.append("[System] GaCy server stopped")
if (stopService) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() }
}
private fun notification(text: String): Notification {
val channel = NotificationChannel(GaCyApplication.CHANNEL_ID, GaCyApplication.CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val pending = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID).setContentTitle("GaCy Android").setContentText(text)
.setSmallIcon(R.drawable.app_icon).setContentIntent(pending).setOngoing(true).build()
}
private fun startForegroundCompat(text: String) {
val n = notification(text)
if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
else startForeground(NOTIFICATION_ID, n)
}
private fun updateNotification(text: String) { getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text)) }
override fun onDestroy() {
++generation
job?.cancel()
terminateProcesses()
scope.cancel()
if (_serverStatus.value != ServerStatus.ERROR) _serverStatus.value = ServerStatus.STOPPED
super.onDestroy()
}
companion object {
@@ -346,51 +179,10 @@ class ServerService : Service() {
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART"
private const val NOTIFICATION_ID = 20261
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
fun start(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (t: Throwable) {
try {
context.startService(intent)
} catch (t2: Throwable) {
LogBuffer.append("[Service Start Error] ${t2.message}")
}
}
}
fun stop(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP }
try {
context.startService(intent)
} catch (t: Throwable) {
LogBuffer.append("[Service Stop Error] ${t.message}")
}
}
fun restart(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (t: Throwable) {
try {
context.startService(intent)
} catch (t2: Throwable) {
LogBuffer.append("[Service Restart Error] ${t2.message}")
}
}
}
fun start(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(ACTION_START)) }
fun stop(context: Context) { context.startService(Intent(context, ServerService::class.java).setAction(ACTION_STOP)) }
fun restart(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(ACTION_RESTART)) }
}
}
@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.AppLanguage
import com.gacy.android.service.ServerService
@@ -24,7 +23,6 @@ enum class NavTab(
val icon: ImageVector
) {
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
@@ -38,7 +36,6 @@ enum class NavTab(
@Composable
fun MainScreen() {
val context = LocalContext.current
val persistentRepo = remember { PersistentRepository(context) }
val prefsRepo = remember { PreferencesRepository(context) }
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
@@ -89,14 +86,8 @@ fun MainScreen() {
NavTab.SERVER -> ServerScreen(
lang = currentLanguage,
serverStatus = serverStatus,
persistentRepo = persistentRepo,
prefsRepo = prefsRepo
)
NavTab.TELEPORT -> TeleportScreen(
lang = currentLanguage,
serverStatus = serverStatus,
persistentRepo = persistentRepo
)
NavTab.SRTOOL -> SRToolScreen(
lang = currentLanguage,
prefsRepo = prefsRepo
@@ -39,15 +39,14 @@ fun SRToolScreen(
val scrollState = rememberScrollState()
val apiRepo = remember { GaCyApiRepository() }
val serverConfig = remember { prefsRepo.getServerConfig() }
var commandInput by remember { mutableStateOf("") }
var commandOutput by remember { mutableStateOf("") }
var commandSuccess by remember { mutableStateOf(true) }
var isExecuting by remember { mutableStateOf(false) }
val presetCommands = listOf(
"/unstuck",
"/give 200001 10000",
"/avatar 1204 80",
"/avatar 1213 80",
"/avatar 1308 80",
@@ -146,9 +145,10 @@ fun SRToolScreen(
Button(
onClick = {
scope.launch {
val res = apiRepo.sendUnstuck(serverConfig)
val res = apiRepo.sendUnstuck(prefsRepo.getServerConfig())
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
commandOutput = "[UNSTUCK] ${res.message}"
commandSuccess = res.success
}
},
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
@@ -185,7 +185,7 @@ fun SRToolScreen(
value = commandInput,
onValueChange = { commandInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(tr(lang, "Enter GM command (e.g. /give 200001 100)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
placeholder = { Text(tr(lang, "Enter GM command (e.g. /help)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
@@ -201,8 +201,9 @@ fun SRToolScreen(
if (commandInput.isNotBlank()) {
scope.launch {
isExecuting = true
val result = apiRepo.executeGmCommand(serverConfig, commandInput)
val result = apiRepo.executeGmCommand(prefsRepo.getServerConfig(), commandInput)
commandOutput = result.message
commandSuccess = result.success
isExecuting = false
}
}
@@ -229,7 +230,7 @@ fun SRToolScreen(
) {
Text(
text = commandOutput,
color = TerminalGreen,
color = if (commandSuccess) TerminalGreen else GaCyRed,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.padding(12.dp)
@@ -1,388 +1,41 @@
package com.gacy.android.ui.screens
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.*
import com.gacy.android.model.AppLanguage
import com.gacy.android.model.ServerStatus
import com.gacy.android.service.LogBuffer
import com.gacy.android.service.ServerService
import com.gacy.android.ui.components.*
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
import com.gacy.android.ui.components.TerminalConsole
import com.gacy.android.ui.components.tr
import com.gacy.android.ui.theme.BackgroundDark
@Composable
fun ServerScreen(
lang: AppLanguage,
serverStatus: ServerStatus,
persistentRepo: PersistentRepository,
prefsRepo: PreferencesRepository
) {
fun ServerScreen(lang: AppLanguage, serverStatus: ServerStatus, prefsRepo: PreferencesRepository) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val logs by LogBuffer.logs.collectAsState()
var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) }
var sendHotfix by remember { mutableStateOf(true) }
var serverKey by remember { mutableStateOf("") }
var tbConfig by remember { mutableStateOf(TrailblazerConfig()) }
var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
// Dialog flags
var showKeyDialog by remember { mutableStateOf(false) }
var showTbDialog by remember { mutableStateOf(false) }
var showExDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
sendHotfix = persistentRepo.getSendHotfix()
serverKey = persistentRepo.getServerKey()
tbConfig = persistentRepo.getTrailblazerConfig()
exConfigs = persistentRepo.getExRankConfigs()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundDark)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Status Card
Surface(
shape = RoundedCornerShape(16.dp),
color = SurfaceDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = tr(lang, "GaCy Server Core", "คอร์เซิร์ฟเวอร์ GaCy", "GaCy 核心服务器"),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = TextPrimary
)
Text(
text = tr(lang, "Ports: 21000 (Dispatch) / 23301 (Game)", "พอร์ต: 21000 (Dispatch) / 23301 (Game)", "端口: 21000 (调度) / 23301 (游戏)"),
fontSize = 12.sp,
color = TextSecondary
)
}
StatusBadge(serverStatus, lang)
}
Spacer(modifier = Modifier.height(16.dp))
// Control Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val isRunning = serverStatus == ServerStatus.RUNNING
Button(
onClick = {
if (isRunning) {
ServerService.stop(context)
} else {
ServerService.start(context)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = if (isRunning) GaCyRed else GaCyPurple
),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.weight(1f)
) {
Icon(
if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
if (isRunning) tr(lang, "STOP", "หยุดเซิร์ฟเวอร์", "停止")
else tr(lang, "START", "เริ่มเซิร์ฟเวอร์", "启动")
)
}
OutlinedButton(
onClick = { ServerService.restart(context) },
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)),
shape = RoundedCornerShape(10.dp)
) {
Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(lang, "Restart", "รีสตาร์ต", "重启"))
}
}
val config = prefsRepo.getServerConfig()
val active = serverStatus == ServerStatus.RUNNING || serverStatus == ServerStatus.STARTING
Column(Modifier.fillMaxSize().background(BackgroundDark).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("GaCy Go — ${serverStatus.name}", style = MaterialTheme.typography.titleLarge)
Text("SDK: ${config.dispatchPort} | Game: ${config.gameserverPort}")
Text(tr(lang, "Set the host in Settings to the address the game uses to reach this device.", "ตั้งค่า Host ในหน้าตั้งค่าเป็น IP ที่เกมใช้เชื่อมต่ออุปกรณ์นี้", "在设置中填写游戏用于连接此设备的地址。"))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { if (active) ServerService.stop(context) else ServerService.start(context) }, enabled = serverStatus != ServerStatus.STOPPING) {
Text(if (active) tr(lang, "Stop", "หยุด", "停止") else tr(lang, "Start", "เริ่ม", "启动"))
}
OutlinedButton(onClick = { ServerService.restart(context) }, enabled = serverStatus != ServerStatus.STARTING && serverStatus != ServerStatus.STOPPING) {
Text(tr(lang, "Restart", "เริ่มใหม่", "重启"))
}
}
// Terminal Console
TerminalConsole(logs = logs, lang = lang)
// Server Configuration Tools
Text(
text = tr(lang, "Server & Character Tools", "เครื่องมือเซิร์ฟเวอร์และตัวละคร", "服务器与角色工具"),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
QuickActionButton(
icon = Icons.Default.VpnKey,
title = tr(lang, "Server Password / Key", "รหัสผ่านเซิร์ฟเวอร์ (Server Key)", "服务器密钥 / 密码"),
subtitle = if (serverKey.isEmpty()) tr(lang, "No password set", "ไม่ได้ตั้งรหัสผ่าน", "未设置密码") else "Key: $serverKey",
color = GaCyPurple
) {
showKeyDialog = true
}
QuickActionButton(
icon = Icons.Default.Person,
title = tr(lang, "Edit Trailblazer (Gender & Path)", "ปรับแต่งผู้บุกเบิก (เพศและ Path)", "开拓者设置 (性别与命途)"),
subtitle = "${tbConfig.gender} | ${tbConfig.tbPath}",
color = GaCyCyan
) {
showTbDialog = true
}
QuickActionButton(
icon = Icons.Default.Star,
title = tr(lang, "3 EX Eidolon (Rank) Manager", "จัดการ 3 EX Eidolon (สกิลพิเศษ)", "3 EX 星魂管理"),
subtitle = exConfigs.filter { it.active }.joinToString(", ") { it.name }.ifEmpty { tr(lang, "None active", "ปิดอยู่ทั้งหมด", "全部未开启") },
color = GaCyAmber
) {
showExDialog = true
}
// Send Hotfix Toggle Card
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.background(GaCyGreen.copy(alpha = 0.2f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.CloudSync, contentDescription = null, tint = GaCyGreen, modifier = Modifier.size(22.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = tr(lang, "Send Hotfix", "ส่ง Hotfix อัตโนมัติ", "自动发送热更新"),
color = TextPrimary,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
Text(
text = if (sendHotfix) tr(lang, "Online Assets Download", "ดาวน์โหลด Assets ออนไลน์", "在线下载资源")
else tr(lang, "Offline Mode (Fix Blackscreen)", "โหมดออฟไลน์ (แก้จอดำ)", "离线模式 (修复黑屏)"),
color = TextSecondary,
fontSize = 12.sp
)
}
}
Switch(
checked = sendHotfix,
onCheckedChange = { checked ->
sendHotfix = checked
scope.launch {
persistentRepo.setSendHotfix(checked)
Toast.makeText(context, tr(lang, "Saved send_hotfix: $checked", "บันทึก send_hotfix แล้ว", "已保存 send_hotfix"), Toast.LENGTH_SHORT).show()
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = TextPrimary,
checkedTrackColor = GaCyGreen,
uncheckedTrackColor = SurfaceDark
)
)
}
}
}
// Dialogs
if (showKeyDialog) {
var tempKey by remember { mutableStateOf(serverKey) }
AlertDialog(
onDismissRequest = { showKeyDialog = false },
title = { Text(tr(lang, "Set Server Password", "กำหนดรหัสผ่านเซิร์ฟเวอร์", "设置服务器密码")) },
text = {
OutlinedTextField(
value = tempKey,
onValueChange = { tempKey = it },
label = { Text(tr(lang, "Password (Optional)", "รหัสผ่าน (เว้นว่างได้)", "密码 (可选)")) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
},
confirmButton = {
TextButton(onClick = {
serverKey = tempKey
scope.launch {
persistentRepo.setServerKey(tempKey)
showKeyDialog = false
Toast.makeText(context, tr(lang, "Server key saved", "บันทึกรหัสผ่านแล้ว", "密码已保存"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyPurple)
}
},
dismissButton = {
TextButton(onClick = { showKeyDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
if (showTbDialog) {
var gender by remember { mutableStateOf(tbConfig.gender) }
var path by remember { mutableStateOf(tbConfig.tbPath) }
AlertDialog(
onDismissRequest = { showTbDialog = false },
title = { Text(tr(lang, "Trailblazer Settings", "ตั้งค่าตัวเอก (ผู้บุกเบิก)", "开拓者设置")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(tr(lang, "Gender:", "เพศ:", "性别:"), color = TextSecondary, fontSize = 12.sp)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
listOf("Female", "Male").forEach { g ->
FilterChip(
selected = gender == g,
onClick = { gender = g },
label = { Text(if (g == "Female") "Stelle (หญิง)" else "Caelus (ชาย)") }
)
}
}
Text(tr(lang, "Path:", "เส้นทาง (Path):", "命途:"), color = TextSecondary, fontSize = 12.sp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("Destruction", "Preservation", "Harmony", "Elation", "Remembrance").forEach { p ->
FilterChip(
selected = path == p,
onClick = { path = p },
label = { Text(p, fontSize = 11.sp) }
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
val updated = tbConfig.copy(gender = gender, tbPath = path)
tbConfig = updated
scope.launch {
persistentRepo.saveTrailblazerConfig(updated)
showTbDialog = false
Toast.makeText(context, tr(lang, "Trailblazer saved", "บันทึกตัวเอกแล้ว", "已保存开拓者"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyCyan)
}
},
dismissButton = {
TextButton(onClick = { showTbDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
if (showExDialog) {
var currentList by remember { mutableStateOf(exConfigs) }
AlertDialog(
onDismissRequest = { showExDialog = false },
title = { Text(tr(lang, "3 EX Eidolon Config", "เปิด/ปิด 3 EX Eidolon", "3 EX 星魂设置")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
currentList.forEachIndexed { index, cfg ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("${cfg.name} [ID: ${cfg.avatarId}]", color = TextPrimary, fontSize = 13.sp)
Switch(
checked = cfg.active,
onCheckedChange = { active ->
val updated = currentList.toMutableList()
updated[index] = cfg.copy(active = active)
currentList = updated
},
colors = SwitchDefaults.colors(checkedTrackColor = GaCyAmber)
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
exConfigs = currentList
scope.launch {
persistentRepo.saveExRankConfigs(currentList)
showExDialog = false
Toast.makeText(context, tr(lang, "EX config saved", "บันทึก EX แล้ว", "已保存 EX 设置"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyAmber)
}
},
dismissButton = {
TextButton(onClick = { showExDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
}
@@ -77,7 +77,7 @@ fun SettingsScreen(
) {
RadioButton(
selected = mode == m,
onClick = { mode = m },
onClick = { mode = m; pingStatus = null },
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
)
Spacer(modifier = Modifier.width(8.dp))
@@ -112,7 +112,7 @@ fun SettingsScreen(
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
onValueChange = { host = it; pingStatus = null },
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -125,7 +125,7 @@ fun SettingsScreen(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = dispatchPort,
onValueChange = { dispatchPort = it },
onValueChange = { dispatchPort = it; pingStatus = null },
label = { Text("Dispatch Port") },
modifier = Modifier.weight(1f),
singleLine = true,
@@ -137,7 +137,7 @@ fun SettingsScreen(
OutlinedTextField(
value = gameserverPort,
onValueChange = { gameserverPort = it },
onValueChange = { gameserverPort = it; pingStatus = null },
label = { Text("Game Port") },
modifier = Modifier.weight(1f),
singleLine = true,
@@ -155,17 +155,15 @@ fun SettingsScreen(
) {
Button(
onClick = {
val dPort = dispatchPort.toIntOrNull() ?: 21000
val gPort = gameserverPort.toIntOrNull() ?: 23301
val newCfg = ServerConfig(
executionMode = mode,
remoteHost = host,
dispatchPort = dPort,
gameserverPort = gPort
)
config = newCfg
prefsRepo.saveServerConfig(newCfg)
Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show()
val newCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
if (newCfg == null) {
Toast.makeText(context, tr(lang, "Enter a valid host and ports from 1 to 65535", "กรอก Host และพอร์ตตั้งแต่ 1 ถึง 65535 ให้ถูกต้อง", "请输入有效主机及 1 至 65535 的端口"), Toast.LENGTH_LONG).show()
} else {
config = newCfg
host = newCfg.remoteHost
prefsRepo.saveServerConfig(newCfg)
Toast.makeText(context, tr(lang, "Saved. Restart the server to apply changes.", "บันทึกแล้ว เริ่มเซิร์ฟเวอร์ใหม่เพื่อใช้ค่า", "已保存,重启服务器后生效。"), Toast.LENGTH_SHORT).show()
}
},
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
shape = RoundedCornerShape(8.dp)
@@ -174,17 +172,22 @@ fun SettingsScreen(
}
OutlinedButton(
enabled = !isPinging,
onClick = {
scope.launch {
val testCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
if (testCfg == null) {
pingStatus = tr(lang, "Invalid host or port", "Host หรือพอร์ตไม่ถูกต้อง", "主机或端口无效")
return@launch
}
isPinging = true
val dPort = dispatchPort.toIntOrNull() ?: 21000
val gPort = gameserverPort.toIntOrNull() ?: 23301
val testCfg = ServerConfig(mode, host, dPort, gPort)
val (ok, duration) = apiRepo.checkServerHealth(testCfg)
pingStatus = if (ok) {
"Connected! Latency: ${duration}ms"
} else {
"Host unreachable or offline"
if (config.withNetworkInput(mode, host, dispatchPort, gameserverPort) == testCfg) {
pingStatus = if (ok) {
"Connected! Latency: ${duration}ms"
} else {
"Host unreachable or offline"
}
}
isPinging = false
}
@@ -228,7 +231,7 @@ fun SettingsScreen(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "GaCy Android v1.0.0\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
text = "GaCy Android ${com.gacy.android.BuildConfig.VERSION_NAME}\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
color = TextSecondary,
fontSize = 12.sp,
lineHeight = 16.sp