Port Android server runtime to GaCy Go
This commit is contained in:
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)) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user