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

@@ -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)) }
}
}