feat: embed standalone local server runtime with Chaquopy Python, auto-installer, and Android 4.6.53 hotfix
This commit is contained in:
1 parent
17bcc1adbf
commit
1f88f6ea38
40 files changed
+6036
-32
No files matched your search
@@ -4,6 +4,8 @@ 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,6 +20,11 @@ class GaCyApplication : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
|
||||
// Initialize embedded Python runtime
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.nio.charset.StandardCharsets
|
||||
class PersistentRepository(private val context: Context) {
|
||||
|
||||
val serverDataDir: File
|
||||
get() = File(context.filesDir, "gacy_server")
|
||||
get() = File(context.filesDir, "server/build/build")
|
||||
|
||||
val miscDir: File
|
||||
get() = File(serverDataDir, "misc")
|
||||
@@ -29,7 +29,41 @@ class PersistentRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
fun isServerInstalled(): Boolean {
|
||||
return File(serverDataDir, "gameserver").exists() || File(serverDataDir, "ppsr.pyc").exists()
|
||||
return File(serverDataDir, "ppsr.pyc").exists() && File(serverDataDir, "ppsr.pyc").length() > 0
|
||||
}
|
||||
|
||||
fun ensureValidPersistentJsonSync() {
|
||||
try {
|
||||
ensureDataStructure()
|
||||
if (persistentFile.exists()) {
|
||||
val text = persistentFile.readText(StandardCharsets.UTF_8)
|
||||
val json = JSONObject(text)
|
||||
var modified = false
|
||||
if (!json.has("send_hotfix")) {
|
||||
json.put("send_hotfix", true)
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("key")) {
|
||||
json.put("key", "")
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("scene") || json.optJSONObject("scene") == null) {
|
||||
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
|
||||
json.put("scene", defaultObj.getJSONObject("scene"))
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("avatar") || json.optJSONObject("avatar") == null) {
|
||||
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
|
||||
json.put("avatar", defaultObj.getJSONObject("avatar"))
|
||||
modified = true
|
||||
}
|
||||
if (modified) {
|
||||
persistentFile.writeText(json.toString(2), StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDataStructure() {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.gacy.android.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class UpdateInfo(
|
||||
val currentVersion: String,
|
||||
val latestVersion: String,
|
||||
val availableVersions: List<String>,
|
||||
val hasUpdate: Boolean
|
||||
)
|
||||
|
||||
class UpdateRepository(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("gacy_updater_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
const val VERSIONS_URL = "https://file.garden/aewBviVxx2XDWgM-/VERSIONS"
|
||||
const val PREF_INSTALLED_VERSION = "installed_server_version"
|
||||
const val CONNECT_TIMEOUT_MS = 15000
|
||||
const val READ_TIMEOUT_MS = 15000
|
||||
}
|
||||
|
||||
fun getInstalledVersion(): String {
|
||||
return prefs.getString(PREF_INSTALLED_VERSION, "4.6.53-embedded") ?: "4.6.53-embedded"
|
||||
}
|
||||
|
||||
fun setInstalledVersion(version: String) {
|
||||
prefs.edit().putString(PREF_INSTALLED_VERSION, version).apply()
|
||||
}
|
||||
|
||||
fun isServerInstalled(): Boolean {
|
||||
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
|
||||
return ppsrFile.exists() && ppsrFile.length() > 0
|
||||
}
|
||||
|
||||
suspend fun ensureServerInstalled(): Boolean = withContext(Dispatchers.IO) {
|
||||
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
|
||||
if (ppsrFile.exists() && ppsrFile.length() > 0) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
try {
|
||||
val stagingDir = File(context.filesDir, "server/staging")
|
||||
val targetServerDir = File(context.filesDir, "server/build/build")
|
||||
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
stagingDir.mkdirs()
|
||||
|
||||
// 1. Extract build.zip from bundled assets
|
||||
extractAssetZip("build.zip", stagingDir)
|
||||
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
|
||||
// 2. Extract runtime packages into effective directory
|
||||
try {
|
||||
extractAssetZip("packages.zip", effectiveStagedDir)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// 3. Move/Rename into place
|
||||
if (targetServerDir.exists()) {
|
||||
targetServerDir.deleteRecursively()
|
||||
}
|
||||
targetServerDir.parentFile?.mkdirs()
|
||||
|
||||
val success = effectiveStagedDir.renameTo(targetServerDir)
|
||||
if (!success) {
|
||||
copyDirectory(effectiveStagedDir, targetServerDir)
|
||||
effectiveStagedDir.deleteRecursively()
|
||||
}
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
|
||||
setInstalledVersion("4.6.53-embedded")
|
||||
return@withContext ppsrFile.exists()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkUpdate(): Result<UpdateInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val versions = fetchAvailableVersions().getOrThrow()
|
||||
if (versions.isEmpty()) {
|
||||
return@withContext Result.failure(IllegalStateException("No versions found from server"))
|
||||
}
|
||||
|
||||
val latestVersion = versions.last()
|
||||
val installed = getInstalledVersion()
|
||||
val hasUpdate = installed.isBlank() || installed != latestVersion
|
||||
|
||||
Result.success(
|
||||
UpdateInfo(
|
||||
currentVersion = installed,
|
||||
latestVersion = latestVersion,
|
||||
availableVersions = versions,
|
||||
hasUpdate = hasUpdate
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAvailableVersions(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(VERSIONS_URL)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("HTTP error ${conn.responseCode}: ${conn.responseMessage}")
|
||||
)
|
||||
}
|
||||
|
||||
val rawText = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val list = rawText.split(Regex("[\\r\\n,]+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
Result.success(list)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDownloadUrl(version: String, filename: String = "build.zip"): String {
|
||||
val sanitizedVersion = version.trim()
|
||||
if (sanitizedVersion.contains("/") || sanitizedVersion.contains("\\") || sanitizedVersion.contains("..")) {
|
||||
throw IllegalArgumentException("Invalid version format")
|
||||
}
|
||||
return "$BASE_URL$sanitizedVersion/$filename"
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(
|
||||
version: String,
|
||||
onProgress: (Float, String) -> Unit
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val tempZip = File(context.cacheDir, "_temp_build_${System.currentTimeMillis()}.zip")
|
||||
val stagingDir = File(context.filesDir, "server/staging")
|
||||
val targetServerDir = File(context.filesDir, "server/build/build")
|
||||
val miscBackupFile = File(context.filesDir, "server/persistent_backup.json")
|
||||
val configBackupFile = File(context.filesDir, "server/config_backup.json")
|
||||
|
||||
try {
|
||||
onProgress(0.05f, "Connecting to download server...")
|
||||
val downloadUrl = buildDownloadUrl(version, "build.zip")
|
||||
val url = URL(downloadUrl)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Failed to download package: HTTP ${conn.responseCode}")
|
||||
)
|
||||
}
|
||||
|
||||
val contentLength = conn.contentLengthLong
|
||||
val inputStream = BufferedInputStream(conn.inputStream)
|
||||
val outputStream = FileOutputStream(tempZip)
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var totalBytesRead = 0L
|
||||
var bytesRead: Int
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = 0.1f + (totalBytesRead.toFloat() / contentLength.toFloat()) * 0.5f
|
||||
val mbRead = totalBytesRead / (1024 * 1024f)
|
||||
val mbTotal = contentLength / (1024 * 1024f)
|
||||
onProgress(progress, String.format("Downloading: %.1f MB / %.1f MB", mbRead, mbTotal))
|
||||
} else {
|
||||
onProgress(0.3f, "Downloading package...")
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
|
||||
onProgress(0.65f, "Backing up user configuration...")
|
||||
val existingPersistent = File(targetServerDir, "misc/persistent.json")
|
||||
if (existingPersistent.exists()) {
|
||||
existingPersistent.copyTo(miscBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
val existingConfig = File(targetServerDir, "misc/config.json")
|
||||
if (existingConfig.exists()) {
|
||||
existingConfig.copyTo(configBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.70f, "Preparing staging directory...")
|
||||
if (stagingDir.exists()) {
|
||||
stagingDir.deleteRecursively()
|
||||
}
|
||||
stagingDir.mkdirs()
|
||||
|
||||
onProgress(0.75f, "Extracting server archive...")
|
||||
extractZipSafely(tempZip, stagingDir)
|
||||
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
|
||||
onProgress(0.80f, "Installing server runtime dependencies...")
|
||||
try {
|
||||
extractAssetZip("packages.zip", effectiveStagedDir)
|
||||
} catch (e: Exception) {
|
||||
// Non-fatal if asset missing
|
||||
}
|
||||
|
||||
onProgress(0.85f, "Restoring user data...")
|
||||
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
|
||||
|
||||
if (miscBackupFile.exists()) {
|
||||
try {
|
||||
val defaultTemplate = JSONObject(PersistentRepository.DEFAULT_PERSISTENT_JSON_STRING)
|
||||
val backedUpText = miscBackupFile.readText()
|
||||
val backedUpJson = JSONObject(backedUpText)
|
||||
if (backedUpJson.has("send_hotfix")) defaultTemplate.put("send_hotfix", backedUpJson.getBoolean("send_hotfix"))
|
||||
if (backedUpJson.has("key")) defaultTemplate.put("key", backedUpJson.getString("key"))
|
||||
if (backedUpJson.has("scene") && backedUpJson.optJSONObject("scene") != null) {
|
||||
defaultTemplate.put("scene", backedUpJson.getJSONObject("scene"))
|
||||
}
|
||||
val backedUpAvatar = backedUpJson.optJSONObject("avatar")
|
||||
if (backedUpAvatar != null) {
|
||||
val avatar = defaultTemplate.getJSONObject("avatar")
|
||||
val keys = backedUpAvatar.keys()
|
||||
while (keys.hasNext()) {
|
||||
val k = keys.next()
|
||||
avatar.put(k, backedUpAvatar.get(k))
|
||||
}
|
||||
}
|
||||
File(stagedMiscDir, "persistent.json").writeText(defaultTemplate.toString(4))
|
||||
} catch (e: Exception) {
|
||||
miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (configBackupFile.exists()) {
|
||||
configBackupFile.copyTo(File(stagedMiscDir, "config.json"), overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.92f, "Finalizing installation...")
|
||||
if (targetServerDir.exists()) {
|
||||
targetServerDir.deleteRecursively()
|
||||
}
|
||||
targetServerDir.parentFile?.mkdirs()
|
||||
|
||||
val success = effectiveStagedDir.renameTo(targetServerDir)
|
||||
if (!success) {
|
||||
copyDirectory(effectiveStagedDir, targetServerDir)
|
||||
effectiveStagedDir.deleteRecursively()
|
||||
}
|
||||
|
||||
setInstalledVersion(version)
|
||||
onProgress(1.0f, "Installation complete!")
|
||||
Result.success(Unit)
|
||||
|
||||
} catch (e: Exception) {
|
||||
onProgress(0f, "Installation error: ${e.message}")
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
if (tempZip.exists()) tempZip.delete()
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun extractAssetZip(assetName: String, outputDir: File) {
|
||||
context.assets.open(assetName).use { inputStream ->
|
||||
ZipInputStream(BufferedInputStream(inputStream)).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
val canonicalDestPath = outputDir.canonicalPath
|
||||
val canonicalNewFilePath = newFile.canonicalPath
|
||||
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
|
||||
canonicalNewFilePath != canonicalDestPath
|
||||
) {
|
||||
throw SecurityException("Zip Slip detected: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
} else {
|
||||
newFile.parentFile?.mkdirs()
|
||||
FileOutputStream(newFile).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZipSafely(zipFile: File, outputDir: File) {
|
||||
ZipInputStream(zipFile.inputStream().buffered()).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
val canonicalDestPath = outputDir.canonicalPath
|
||||
val canonicalNewFilePath = newFile.canonicalPath
|
||||
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
|
||||
canonicalNewFilePath != canonicalDestPath
|
||||
) {
|
||||
throw SecurityException("Zip Slip detected: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
} else {
|
||||
newFile.parentFile?.mkdirs()
|
||||
FileOutputStream(newFile).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirectory(source: File, target: File) {
|
||||
if (source.isDirectory) {
|
||||
if (!target.exists()) target.mkdirs()
|
||||
source.list()?.forEach { file ->
|
||||
copyDirectory(File(source, file), File(target, file))
|
||||
}
|
||||
} else {
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ enum class ServerStatus {
|
||||
STOPPED,
|
||||
STARTING,
|
||||
RUNNING,
|
||||
STOPPING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,43 @@ import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
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 androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.chaquo.python.Python
|
||||
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.PreferencesRepository
|
||||
import com.gacy.android.data.UpdateRepository
|
||||
import com.gacy.android.model.ServerExecutionMode
|
||||
import com.gacy.android.model.ServerStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
class ServerService : LifecycleService() {
|
||||
|
||||
private var serverThread: Thread? = null
|
||||
private var probeJob: Job? = null
|
||||
private var serverJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -52,56 +70,150 @@ class ServerService : LifecycleService() {
|
||||
val config = prefs.getServerConfig()
|
||||
|
||||
_serverStatus.value = ServerStatus.STARTING
|
||||
startForeground(NOTIFICATION_ID, createNotification("GaCy Server is 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}")
|
||||
|
||||
serverJob = lifecycleScope.launch {
|
||||
try {
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||
LogBuffer.append("Initializing local server runtime...")
|
||||
delay(500)
|
||||
LogBuffer.append("Loading resources and protocol definitions...")
|
||||
delay(500)
|
||||
LogBuffer.append("Dispatch service listening on 0.0.0.0:${config.dispatchPort}")
|
||||
LogBuffer.append("Gateway KCP socket bound on 0.0.0.0:${config.gameserverPort}")
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("GaCy Server is RUNNING on :${config.dispatchPort}")
|
||||
LogBuffer.append("GaCy Server is ready! Connect client via dispatch redirect.")
|
||||
} else {
|
||||
LogBuffer.append("Connecting to remote GaCy host at ${config.remoteHost}...")
|
||||
delay(500)
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("Monitoring Remote GaCy Host at ${config.remoteHost}")
|
||||
LogBuffer.append("Connected to remote GaCy server monitor.")
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||
startLocalServerExecution(config.dispatchPort)
|
||||
} else {
|
||||
startRemoteMonitor(config.remoteHost, config.dispatchPort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocalServerExecution(dispatchPort: Int) {
|
||||
serverJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||
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
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogBuffer.append("Error starting server: ${e.message}")
|
||||
_serverStatus.value = ServerStatus.ERROR
|
||||
updateNotification("GaCy Server encountered an error")
|
||||
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: Exception) {
|
||||
LogBuffer.append("[Warning] Persistent validation: ${e.message}")
|
||||
}
|
||||
|
||||
LogBuffer.append("[System] Launching embedded GaCy Python server...")
|
||||
|
||||
// Background TCP port readiness probe
|
||||
probeJob?.cancel()
|
||||
probeJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||
var attempts = 0
|
||||
while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 35) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serverThread = Thread({
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val runPpsr = py.getModule("run_ppsr")
|
||||
val logReceiver = ServerLogReceiver()
|
||||
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
|
||||
} catch (e: Exception) {
|
||||
LogBuffer.append("[Python Error] ${e.localizedMessage ?: e.message}")
|
||||
} finally {
|
||||
probeJob?.cancel()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
LogBuffer.append("[System] Server stopped.")
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
}, "GaCy-PythonServer").apply {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) {
|
||||
serverJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||
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 stopServer() {
|
||||
LogBuffer.append("--- GaCy Server Stopping ---")
|
||||
_serverStatus.value = ServerStatus.STOPPING
|
||||
updateNotification("Stopping GaCy Server...")
|
||||
|
||||
probeJob?.cancel()
|
||||
probeJob = null
|
||||
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (Python.isStarted()) {
|
||||
val py = Python.getInstance()
|
||||
val runPpsr = py.getModule("run_ppsr")
|
||||
runPpsr.callAttr("kill_server_process")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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()
|
||||
lifecycleScope.launch {
|
||||
delay(800)
|
||||
delay(1500)
|
||||
startServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(host: String, port: Int): Boolean {
|
||||
return try {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress(host, port), 400)
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(contentText: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
@@ -133,14 +245,38 @@ class ServerService : LifecycleService() {
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(text: String) {
|
||||
val notification = createNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(contentText: String) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, createNotification(contentText))
|
||||
}
|
||||
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
probeJob?.cancel()
|
||||
serverThread?.interrupt()
|
||||
serverJob?.cancel()
|
||||
super.onDestroy()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
}
|
||||
|
||||
@@ -160,7 +296,11 @@ class ServerService : LifecycleService() {
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
|
||||
context.startForegroundService(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
@@ -170,7 +310,11 @@ class ServerService : LifecycleService() {
|
||||
|
||||
fun restart(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
|
||||
context.startForegroundService(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ fun StatusBadge(status: ServerStatus, lang: AppLanguage) {
|
||||
val (color, text) = when (status) {
|
||||
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
|
||||
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
|
||||
ServerStatus.STOPPING -> Pair(GaCyAmber, tr(lang, "STOPPING...", "กำลังหยุด...", "停止中..."))
|
||||
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
|
||||
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user