Files
GaCyGO-Android/app/src/main/java/com/gacy/android/service/ServerService.kt
T

189 lines
8.7 KiB
Kotlin

package com.gacy.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.gacy.android.GaCyApplication
import com.gacy.android.MainActivity
import com.gacy.android.R
import com.gacy.android.data.GaCyApiRepository
import com.gacy.android.data.GoRuntimeRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.ServerConfig
import com.gacy.android.model.ServerExecutionMode
import com.gacy.android.model.ServerStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.util.concurrent.TimeUnit
class ServerService : Service() {
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 onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action ?: ACTION_START) {
ACTION_START -> startServer()
ACTION_STOP -> stopServer()
ACTION_RESTART -> { stopServer(false); startServer() }
}
return START_NOT_STICKY
}
private fun startServer() {
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 {
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)
}
}
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)
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")
}
}
} catch (_: CancellationException) {
} catch (e: Exception) {
if (run == generation) {
LogBuffer.append("[Server error] ${e.message}")
terminateProcesses()
_serverStatus.value = ServerStatus.ERROR
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
}
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 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
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 {
const val ACTION_START = "com.gacy.android.ACTION_START"
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) { 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)) }
}
}