189 lines
8.7 KiB
Kotlin
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)) }
|
|
}
|
|
}
|