Port Android server runtime to GaCy Go
This commit is contained in:
1 parent
92a4b4f545
commit
3c1186c781
20 files changed
+824
-792
No files matched your search
@@ -19,3 +19,6 @@ app/build/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
app/src/main/jniLibs/arm64-v8a/libgacy_*.so
|
||||
app/src/goAssets/gacy-go-data.zip
|
||||
app/src/debug/jniLibs/x86_64/libgacy_*.so
|
||||
@@ -1,4 +1,49 @@
|
||||
# 📱 GaCy Android (if you have client and this ps is working pls tell me)
|
||||
# 📱 GaCy Android
|
||||
|
||||
> **Go port in progress:** the local server now launches the SDK and game
|
||||
> processes built from GaC-hkrpg-go. Teleporting uses the game's own map;
|
||||
> the older ppSR character settings are not yet connected. On 1 November 2026,
|
||||
> the user confirmed that an ARM64 debug build could enter the game and teleport
|
||||
> using the in-game map on the same phone. On 2 November, the user confirmed
|
||||
> that the updated build started the server, entered the game and restarted the
|
||||
> server successfully. Other features still need validation.
|
||||
|
||||
The Go SDK, shared-data, game-server and KCP package tests have also passed;
|
||||
these automated tests do not establish that every gameplay feature works on
|
||||
the Android client.
|
||||
|
||||
To build the embedded runtime, use Go and Python with a local checkout of
|
||||
GaC-hkrpg-go, then build the app:
|
||||
|
||||
```powershell
|
||||
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
After a successful debug build, Gradle also copies the APK to the project root
|
||||
as `GaCy-Android-<versionName>-debug.apk`. APK files are excluded by `.gitignore`.
|
||||
|
||||
The packaging script uses the checkout's current source and resources and
|
||||
seeds player data from its committed defaults. Runtime saves are stored under
|
||||
the app's `files/gacy-go` directory; the previous ppSR data remains separate.
|
||||
When runtime data is updated, new entries from bundled `hotfix.json` and
|
||||
`versions.json` are added without replacing entries already on the device.
|
||||
If a previous failed lookup left an entirely empty cached entry, the SDK uses
|
||||
available bundled metadata for that version instead of the empty entry.
|
||||
Set the Host in Settings to the address the game client should use to reach
|
||||
the phone (use `127.0.0.1` when both run on the same device). The Go servers
|
||||
use the configured SDK and game ports. Redirecting the game client's dispatch
|
||||
traffic is still a separate setup step.
|
||||
|
||||
Network settings take effect after restarting the server. Remote mode checks
|
||||
the SDK health endpoint periodically and reports an error after three
|
||||
consecutive failed checks.
|
||||
The connection test clears its previous result when the host, ports or mode
|
||||
change, and tests the values currently entered in Settings.
|
||||
|
||||
For an x86_64 Android emulator only, add `--emulator` to the packaging
|
||||
command and `-PgacyEmulator` to the Gradle command. This adds Linux x86_64
|
||||
test executables to the debug APK; ordinary builds contain Android ARM64 only.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -22,24 +67,17 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
|
||||
### ✨ ฟีเจอร์หลัก (Key Features)
|
||||
|
||||
* **🎮 ระบบจัดการเซิร์ฟเวอร์แบบ Dual-Mode (Local & Remote Server Management):**
|
||||
* **โหมด Local Embedded:** รันเซิร์ฟเวอร์จำลองภายในอุปกรณ์ Android โดยตรงผ่าน Android Foreground Service (`specialUse`) ไม่ถูกระบบฆ่าขณะสลับไปเล่นเกม
|
||||
* **โหมด Local Embedded:** รันเซิร์ฟเวอร์จำลองภายในอุปกรณ์ Android ผ่าน Android Foreground Service (`specialUse`)
|
||||
* **โหมด Remote Host:** เชื่อมต่อและควบคุมเซิร์ฟเวอร์ GaCy-GO บน PC ผ่าน Wi-Fi/LAN พร้อมระบบทดสอบค่าความหน่วง (Ping Latency Test)
|
||||
* **Live Terminal Console:** หน้าต่างแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบ Real-time พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear)
|
||||
* **🗺️ ระบบวาร์ปแผนที่ 8 โลก 86 ฉาก (Escape Penacony Teleport Engine):**
|
||||
* ครอบคลุม **8 ดวงดาว 86+ ฉาก** จากฐานข้อมูล `scenes.json` (Astral Express, Herta Space Station, Jarilo-VI, Xianzhou Luofu, Penacony, และแมพเบต้าล่าสุดอย่าง Amphoreus / Planarcadia Beta `20572`)
|
||||
* ระบบตัวกรองอัจฉริยะ: ⚔️ **Combat Ready** (มีเสา Calyx สำหรับต่อสู้), 🗺️ **Safe Explore** (เดินเล่นปลอดภัย), 🧪 **Beta Exclusive** (ฉากพิเศษสำหรับเบต้า)
|
||||
* ค้นหาฉากได้รวดเร็วทั้งภาษาไทย ภาษาอังกฤษ ภาษาจีน หรือกรอก Plane ID
|
||||
* **Custom Coordinates Teleport:** กรอกพิกัด Plane ID, X, Y, Z, Map Layer เองได้อิสระ
|
||||
* ระบบบันทึกไฟล์คอนฟิก `persistent.json` แบบ **Atomic Operation** ปลอดภัย 100% ไม่สร้างความเสียหายแก่ข้อมูลตัวละคร
|
||||
* **🗺️ วาร์ปในเกม:** ใช้แผนที่ของเกมผ่าน Game server ของ GaCy Go โดยตรง
|
||||
* **⚡ คอนโซลคำสั่ง GM บนมือถือ & เชื่อมต่อ GaCy SRTool:**
|
||||
* **Emergency Unstuck:** ปุ่มปลดบล็อกตัวละครเร่งด่วนผ่าน Heartbeat API (`/api/client/unstuck`) กู้คืนอาการเกมค้าง
|
||||
* **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM (`/give`, `/avatar`, `/faketime`) ตรงไปยังเซิร์ฟเวอร์ พร้อมปุ่มคีย์ลัดคำสั่งยอดฮิต
|
||||
* **Emergency Unstuck:** ส่งคำขอกู้คืน UI ผ่าน Heartbeat API (`/api/client/unstuck`) แอปแสดงว่าเข้าคิวเมื่อเซิร์ฟเวอร์ตอบกลับพร้อมรหัสคำขอเท่านั้น; การเข้าคิวไม่ได้ยืนยันว่าเกมประมวลผลสำเร็จ
|
||||
* **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM ไปยังเซิร์ฟเวอร์; ความพร้อมใช้งานขึ้นกับ Go runtime โดย `/give` และ `/heal` ยังไม่รองรับ
|
||||
* **`/max`:** ตั้งตัวละครทั้งหมดเป็นเลเวล 80, ขั้นเลื่อน 6 และแรงก์ 6 โดยแก้เฉพาะข้อมูลตัวละครที่เกี่ยวข้องและเก็บฟิลด์อื่นไว้ ต้องเชื่อมต่อเกมใหม่เพื่อโหลดค่า การทดสอบอัตโนมัติผ่านแล้ว แต่ยังไม่ได้ยืนยันคำสั่งนี้ในเกมจริง
|
||||
* **`/teleport <plane_id> [floor_id]`:** บันทึกค่าฉากลง persistent โดยไม่เขียนไฟล์นำเข้าตัวละครทับ ต้องเชื่อมต่อเกมใหม่ และไม่แทนการวาร์ปผ่านแผนที่ในเกม; ผ่านการทดสอบอัตโนมัติ แต่ยังไม่ได้ยืนยันคำสั่งนี้ในเกมจริง คำสั่ง `/clear lineup` ถูกปฏิเสธเพราะทีมที่ใช้อยู่ต้องไม่ว่าง
|
||||
* **GaCy SRTool Web Integration:** ปุ่มลัดเปิดหน้าจัดการกระเป๋า ตัวละคร และสร้าง Relic ผ่าน [GaCy SRTool Web](https://gacy-srtool.vercel.app/)
|
||||
* **🛠️ เครื่องมือปรับแต่งตัวละครและเซิร์ฟเวอร์ (Character & Server Configurator):**
|
||||
* **🔑 Server Key / Password:** ตั้งรหัสผ่านเซิร์ฟเวอร์เพื่อความปลอดภัย
|
||||
* **👤 Trailblazer Configurator:** สลับเพศผู้บุกเบิก (Stelle หญิง / Caelus ชาย) และเลือก Path (ทำลายล้าง, อนุรักษ์, ประสาน, ลบล้าง, ปิติสุข) ได้ทันที
|
||||
* **⭐ 3 EX Eidolon (Rank) Manager:** สลับเปิด/ปิดสกิลพิเศษ 3 EX สำหรับตัวละครยอดนิยม (JingYuan [1204], DanHeng IL [1213], Acheron [1308])
|
||||
* **⚡ Send Hotfix (Online/Offline Toggle):** เปิดสำหรับดาวน์โหลด Assets ออนไลน์จาก Hoyoverse หรือปิดเพื่อเล่นแบบออฟไลน์ 100% แก้ปัญหาหน้าจอดำ
|
||||
* **⚙️ การตั้งค่า:** ตั้งค่าการเชื่อมต่อและภาษาของแอป; การตั้งค่าตัวละครจาก ppSR รุ่นเก่ายังไม่ได้เชื่อมกับ Go runtime
|
||||
* **🎨 หน้าต่าง Cyberpunk Dark สวยงาม ลื่นไหล:**
|
||||
* ออกแบบด้วย **Material Design 3** สีสันเข้าชุดกับ G-Launcher Desktop และ GaCy-GO
|
||||
* รองรับ 3 ภาษาภายในตัว: **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)**
|
||||
@@ -53,11 +91,9 @@ graph TD
|
||||
subgraph AppLayer [UI Layer: Jetpack Compose]
|
||||
MainUI[MainScreen & Navigation]
|
||||
ServerTab[ServerScreen & Live Terminal]
|
||||
TeleportTab[TeleportScreen & 86 Scenes]
|
||||
SRToolTab[SRToolScreen & GM Console]
|
||||
SettingsTab[SettingsScreen & Network Config]
|
||||
MainUI --> ServerTab
|
||||
MainUI --> TeleportTab
|
||||
MainUI --> SRToolTab
|
||||
MainUI --> SettingsTab
|
||||
end
|
||||
@@ -70,27 +106,22 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph DataRepositories [Data & Repository Layer]
|
||||
SceneRepo[SceneRepository - scenes.json Cache]
|
||||
PersistRepo[PersistentRepository - persistent.json]
|
||||
ApiRepo[GaCyApiRepository - OkHttp Client]
|
||||
PrefsRepo[PreferencesRepository - SharedPreferences]
|
||||
end
|
||||
|
||||
subgraph GameEcosystem [GaCy Ecosystem & Storage]
|
||||
GameServer[GaCy-GO Server / Remote PC]
|
||||
PersistentData[persistent.json / misc Configs]
|
||||
PersistentData[Go runtime saves and resources]
|
||||
SRToolWeb[GaCy SRTool Web Application]
|
||||
StarRailClient[Star Rail Game Client Android]
|
||||
end
|
||||
|
||||
ServerTab --> PersistRepo
|
||||
ServerTab --> ServerFGS
|
||||
TeleportTab --> SceneRepo
|
||||
TeleportTab --> PersistRepo
|
||||
ServerFGS --> PersistentData
|
||||
SRToolTab --> ApiRepo
|
||||
SettingsTab --> PrefsRepo
|
||||
|
||||
PersistRepo --> PersistentData
|
||||
ApiRepo --> GameServer
|
||||
SRToolTab -.-> SRToolWeb
|
||||
StarRailClient -.-> GameServer
|
||||
|
||||
+38
-5
@@ -12,20 +12,21 @@ android {
|
||||
applicationId = "com.gacy.android"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "v1.0.0"
|
||||
versionCode = 7
|
||||
versionName = "v1.0.6"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
if (providers.gradleProperty("gacyEmulator").isPresent) abiFilters += "x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
assets.srcDirs("src/main/assets")
|
||||
assets.setSrcDirs(listOf("src/goAssets"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +51,16 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
keepDebugSymbols += "**/libgacy_*.so"
|
||||
excludes += listOf("**/libchaquopy*.so", "**/libpython*.so", "**/libcrypto_*.so", "**/libsqlite3_*.so", "**/libssl_*.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files("libs/chaquopy_java.jar"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
||||
@@ -72,3 +78,30 @@ dependencies {
|
||||
testImplementation("org.json:json:20240303")
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
|
||||
tasks.named("preBuild") {
|
||||
doFirst {
|
||||
val required = listOf(
|
||||
"src/goAssets/gacy-go-data.zip",
|
||||
"src/main/jniLibs/arm64-v8a/libgacy_sdkserver.so",
|
||||
"src/main/jniLibs/arm64-v8a/libgacy_gameserver.so"
|
||||
)
|
||||
check(required.all { file(it).isFile }) {
|
||||
"GaCy Go runtime is missing. Run: python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val copyDebugApkToRoot by tasks.registering {
|
||||
doNotTrackState("Copies the APK into the project root")
|
||||
onlyIf { tasks.named("assembleDebug").get().state.failure == null }
|
||||
doLast {
|
||||
val source = layout.buildDirectory.file("outputs/apk/debug/app-debug.apk").get().asFile
|
||||
check(source.isFile) { "Debug APK was not produced" }
|
||||
source.copyTo(rootProject.file("GaCy-Android-${android.defaultConfig.versionName}-debug.apk"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "assembleDebug" }.configureEach {
|
||||
finalizedBy(copyDebugApkToRoot)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.gacy.android
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object CrashTracker {
|
||||
private const val PREF_NAME = "gacy_crash_tracker"
|
||||
private const val KEY_LAST_CRASH = "last_crash"
|
||||
private const val KEY_BREADCRUMBS = "breadcrumbs"
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
fun init(context: Context) {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
recordCrash(context, thread, throwable)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordCrash(context: Context, thread: Thread, throwable: Throwable) {
|
||||
try {
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
val stackTrace = sw.toString()
|
||||
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val breadcrumbs = prefs.getString(KEY_BREADCRUMBS, "None") ?: "None"
|
||||
val fullReport = "Thread: ${thread.name}\nTimestamp: ${timeFormat.format(Date())}\n\nRecent Actions:\n$breadcrumbs\n\nStacktrace:\n$stackTrace"
|
||||
|
||||
prefs.edit().putString(KEY_LAST_CRASH, fullReport).commit()
|
||||
|
||||
// Also write directly to disk file for redundancy
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
crashFile.writeText(fullReport)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun breadcrumb(context: Context, text: String) {
|
||||
try {
|
||||
val timestamp = timeFormat.format(Date())
|
||||
val entry = "[$timestamp] $text"
|
||||
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val current = prefs.getString(KEY_BREADCRUMBS, "") ?: ""
|
||||
val updated = if (current.isBlank()) entry else "$current\n$entry"
|
||||
val lines = updated.lines().takeLast(15).joinToString("\n")
|
||||
prefs.edit().putString(KEY_BREADCRUMBS, lines).apply()
|
||||
|
||||
val breadcrumbFile = File(context.filesDir, "breadcrumbs.txt")
|
||||
breadcrumbFile.appendText("$entry\n")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun getLastCrash(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val fromPrefs = prefs.getString(KEY_LAST_CRASH, null)
|
||||
if (!fromPrefs.isNullOrBlank()) return fromPrefs
|
||||
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
if (crashFile.exists() && crashFile.length() > 0) {
|
||||
return crashFile.readText()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun clearLastCrash(context: Context) {
|
||||
try {
|
||||
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_LAST_CRASH)
|
||||
.apply()
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
if (crashFile.exists()) crashFile.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ 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,17 +16,11 @@ class GaCyApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashTracker.init(this)
|
||||
CrashTracker.breadcrumb(this, "GaCyApplication: onCreate")
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
|
||||
// Initialize embedded Python runtime
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
package com.gacy.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.gacy.android.ui.screens.MainScreen
|
||||
import com.gacy.android.ui.theme.GaCyTheme
|
||||
|
||||
@@ -16,12 +37,83 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
CrashTracker.breadcrumb(this, "MainActivity: onCreate")
|
||||
|
||||
// ซ่อน Status Bar
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) // ใช้ .systemBars() ถ้าต้องการซ่อน Navigation Bar ด้านล่างด้วย
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestAppPermissions()
|
||||
|
||||
setContent {
|
||||
GaCyTheme {
|
||||
var crashReport by remember { mutableStateOf(CrashTracker.getLastCrash(this@MainActivity)) }
|
||||
|
||||
MainScreen()
|
||||
|
||||
if (crashReport != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
CrashTracker.clearLastCrash(this@MainActivity)
|
||||
crashReport = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
"⚠️ GaCy Crash Report",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"A fatal error occurred in the previous session:",
|
||||
fontSize = 13.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = crashReport ?: "",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.background(Color(0xFF1E1E1E), RoundedCornerShape(8.dp))
|
||||
.padding(8.dp),
|
||||
color = Color(0xFFFF8A80)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
val clip = ClipData.newPlainText("GaCy Crash", crashReport)
|
||||
clipboard?.setPrimaryClip(clip)
|
||||
Toast.makeText(this@MainActivity, "Crash report copied!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy Log")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
CrashTracker.clearLastCrash(this@MainActivity)
|
||||
crashReport = null
|
||||
}
|
||||
) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,42 +9,44 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class GaCyApiRepository {
|
||||
internal fun parseUnstuckResponse(body: String): GmCommandResult {
|
||||
return try {
|
||||
val result = JSONObject(body)
|
||||
val requestId = result.opt("request_id") as? String
|
||||
if (result.optString("state") == "queued" && !requestId.isNullOrBlank()) {
|
||||
GmCommandResult(true, "Unstuck request queued; waiting for the game client to process it.")
|
||||
} else {
|
||||
GmCommandResult(false, "Server did not confirm an unstuck request.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
GmCommandResult(false, "Server returned an invalid unstuck response.")
|
||||
}
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
class GaCyApiRepository(private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
.build()) {
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
val url = "${config.dispatchUrl}/status"
|
||||
val url = "${config.dispatchUrl}/api/status"
|
||||
try {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Pair(response.isSuccessful, duration)
|
||||
val payload = response.body?.string().orEmpty()
|
||||
Pair(response.isSuccessful && JSONObject(payload).optInt("status") == 200, duration)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Try query_gateway fallback
|
||||
try {
|
||||
val gwUrl = "${config.dispatchUrl}/query_gateway"
|
||||
val request = Request.Builder().url(gwUrl).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Pair(response.isSuccessful || response.code == 400 || response.code == 200, duration)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Pair(false, -1L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendUnstuck(config: ServerConfig): GmCommandResult = withContext(Dispatchers.IO) {
|
||||
val url = "${config.dispatchUrl}/api/client/unstuck"
|
||||
@@ -53,19 +55,19 @@ class GaCyApiRepository {
|
||||
val request = Request.Builder().url(url).post(body).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
GmCommandResult(true, "Unstuck packet sent to game client successfully.")
|
||||
parseUnstuckResponse(response.body?.string().orEmpty())
|
||||
} else {
|
||||
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
GmCommandResult(false, "Network error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
|
||||
val cleanCmd = command.trim()
|
||||
val url = "${config.dispatchUrl}/api/gm/command"
|
||||
val url = "${config.dispatchUrl}/api/command"
|
||||
try {
|
||||
val payload = JSONObject().apply {
|
||||
put("command", cleanCmd)
|
||||
@@ -75,7 +77,11 @@ class GaCyApiRepository {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
if (response.isSuccessful) {
|
||||
GmCommandResult(true, responseBody.ifEmpty { "Command executed successfully: $cleanCmd" })
|
||||
val result = JSONObject(responseBody)
|
||||
val success = result.optBoolean("success", false)
|
||||
GmCommandResult(success, result.optString("output").ifBlank {
|
||||
result.optString("error", "Server returned an invalid command result")
|
||||
})
|
||||
} else {
|
||||
GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.gacy.android.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/** Runtime files for the Go server. Never replace the server's mutable data. */
|
||||
class GoRuntimeRepository(private val context: Context) {
|
||||
val directory = File(context.filesDir, "gacy-go")
|
||||
|
||||
fun prepare() = GoRuntimeInstaller(directory) { context.assets.open("gacy-go-data.zip") }.prepare()
|
||||
}
|
||||
|
||||
/** Install resources without replacing existing saves or extracting concurrently. */
|
||||
internal class GoRuntimeInstaller(private val directory: File, private val openArchive: () -> InputStream) {
|
||||
companion object {
|
||||
private val installLock = Any()
|
||||
private val required = listOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json", "res.json", "teleports.json", "resources/MapEntrance.json")
|
||||
}
|
||||
|
||||
private fun replaceFile(temp: File, target: File) {
|
||||
try {
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare() = synchronized(installLock) {
|
||||
val marker = File(directory, ".asset-version")
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
openArchive().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
// Bump the installer revision when existing archives need a new merge.
|
||||
val version = "2:" + digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (marker.isFile && marker.readText() == version && required.all { File(directory, it).isFile }) return@synchronized
|
||||
directory.mkdirs()
|
||||
val root = directory.canonicalFile
|
||||
val mutable = setOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json")
|
||||
ZipInputStream(openArchive().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
val target = File(directory, entry.name).canonicalFile
|
||||
require(target.path.startsWith(root.path + File.separator)) { "Invalid runtime asset path" }
|
||||
if (entry.isDirectory) target.mkdirs()
|
||||
else if (entry.name in setOf("hotfix.json", "versions.json") && target.exists()) {
|
||||
// Add new bundled versions without replacing operator overrides.
|
||||
val existing = JSONObject(target.readText())
|
||||
val bundled = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
|
||||
val keys = bundled.keys()
|
||||
var changed = false
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
if (!existing.has(key)) { existing.put(key, bundled.get(key)); changed = true }
|
||||
}
|
||||
if (changed) {
|
||||
val temp = File(target.parentFile, target.name + ".installing")
|
||||
temp.writeText(existing.toString(2))
|
||||
replaceFile(temp, target)
|
||||
}
|
||||
}
|
||||
else if (entry.name !in mutable || !target.exists()) {
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, target.name + ".installing")
|
||||
temp.outputStream().use { zip.copyTo(it) }
|
||||
replaceFile(temp, target)
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
check(required.all { File(directory, it).isFile }) { "Go runtime archive is incomplete" }
|
||||
marker.writeText(version)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.gacy.android.model
|
||||
|
||||
import java.net.URI
|
||||
|
||||
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
|
||||
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
|
||||
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
|
||||
@@ -22,7 +24,19 @@ data class ServerConfig(
|
||||
val sendHotfix: Boolean = true
|
||||
) {
|
||||
val dispatchUrl: String
|
||||
get() = "http://$remoteHost:$dispatchPort"
|
||||
get() = "http://${if (executionMode == ServerExecutionMode.LOCAL_EMBEDDED) "127.0.0.1" else remoteHost}:$dispatchPort"
|
||||
|
||||
/** Parse the host and ports the same way they will be used by the server. */
|
||||
fun withNetworkInput(mode: ServerExecutionMode, host: String, sdkPort: String, gamePort: String): ServerConfig? {
|
||||
val cleanHost = host.trim()
|
||||
val sdk = sdkPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
|
||||
val game = gamePort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
|
||||
val address = try { URI("http://$cleanHost") } catch (_: Exception) { return null }
|
||||
if (address.host.isNullOrBlank() || address.port != -1 || address.userInfo != null ||
|
||||
!address.rawPath.isNullOrEmpty() || address.rawQuery != null || address.rawFragment != null ||
|
||||
cleanHost.contains(':') || cleanHost == "0.0.0.0") return null
|
||||
return copy(executionMode = mode, remoteHost = cleanHost, dispatchPort = sdk, gameserverPort = game)
|
||||
}
|
||||
}
|
||||
|
||||
data class GmCommandResult(
|
||||
|
||||
@@ -10,7 +10,9 @@ import java.util.Locale
|
||||
object LogBuffer {
|
||||
|
||||
private const val MAX_LOG_LINES = 500
|
||||
private const val MAX_DIAGNOSTIC_LINES = 100
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
private val diagnostics = ArrayDeque<String>()
|
||||
|
||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||
@@ -19,6 +21,10 @@ object LogBuffer {
|
||||
fun append(message: String) {
|
||||
val timestamp = timeFormat.format(Date())
|
||||
val formatted = "[$timestamp] $message"
|
||||
if (isDiagnostic(message)) {
|
||||
diagnostics.addLast(formatted)
|
||||
if (diagnostics.size > MAX_DIAGNOSTIC_LINES) diagnostics.removeFirst()
|
||||
}
|
||||
val current = _logs.value.toMutableList()
|
||||
current.add(formatted)
|
||||
if (current.size > MAX_LOG_LINES) {
|
||||
@@ -30,9 +36,18 @@ object LogBuffer {
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
_logs.value = emptyList()
|
||||
diagnostics.clear()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getAllText(): String {
|
||||
return _logs.value.joinToString("\n")
|
||||
val recent = _logs.value
|
||||
val olderDiagnostics = diagnostics.filterNot { it in recent }
|
||||
return (olderDiagnostics + recent).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun isDiagnostic(message: String): Boolean =
|
||||
"[gateway]" in message || "[DISPATCH]" in message ||
|
||||
"[KCP] handshake rejected:" in message || "[KCP] packet rejected:" in message ||
|
||||
"New connection from addr:" in message || "[Server error]" in message
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
if (_serverStatus.value == ServerStatus.STARTING || _serverStatus.value == ServerStatus.RUNNING) return
|
||||
val run = ++generation
|
||||
val config = PreferencesRepository(this).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}")
|
||||
|
||||
startForegroundCompat("Starting GaCy Go server")
|
||||
job = scope.launch {
|
||||
try {
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||
startLocalServerExecution(config.dispatchPort)
|
||||
} else {
|
||||
startRemoteMonitor(config.remoteHost, config.dispatchPort)
|
||||
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)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogBuffer.append("[Start Error] ${t.localizedMessage ?: t.message}")
|
||||
t.printStackTrace()
|
||||
_serverStatus.value = ServerStatus.ERROR
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocalServerExecution(dispatchPort: Int) {
|
||||
executionJob?.cancel()
|
||||
executionJob = serviceScope.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
|
||||
}
|
||||
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 (t: Throwable) {
|
||||
LogBuffer.append("[Execution Error] ${t.localizedMessage ?: t.message}")
|
||||
t.printStackTrace()
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
if (run == generation) {
|
||||
LogBuffer.append("[Server error] ${e.message}")
|
||||
terminateProcesses()
|
||||
_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 stopServer() {
|
||||
LogBuffer.append("--- GaCy Server Stopping ---")
|
||||
_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)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
probeJob?.cancel()
|
||||
serverThread?.interrupt()
|
||||
executionJob?.cancel()
|
||||
super.onDestroy()
|
||||
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 {
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.gacy.android.data.PersistentRepository
|
||||
import com.gacy.android.data.PreferencesRepository
|
||||
import com.gacy.android.model.AppLanguage
|
||||
import com.gacy.android.service.ServerService
|
||||
@@ -24,7 +23,6 @@ enum class NavTab(
|
||||
val icon: ImageVector
|
||||
) {
|
||||
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
|
||||
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
|
||||
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
|
||||
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
|
||||
|
||||
@@ -38,7 +36,6 @@ enum class NavTab(
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val context = LocalContext.current
|
||||
val persistentRepo = remember { PersistentRepository(context) }
|
||||
val prefsRepo = remember { PreferencesRepository(context) }
|
||||
|
||||
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
|
||||
@@ -89,14 +86,8 @@ fun MainScreen() {
|
||||
NavTab.SERVER -> ServerScreen(
|
||||
lang = currentLanguage,
|
||||
serverStatus = serverStatus,
|
||||
persistentRepo = persistentRepo,
|
||||
prefsRepo = prefsRepo
|
||||
)
|
||||
NavTab.TELEPORT -> TeleportScreen(
|
||||
lang = currentLanguage,
|
||||
serverStatus = serverStatus,
|
||||
persistentRepo = persistentRepo
|
||||
)
|
||||
NavTab.SRTOOL -> SRToolScreen(
|
||||
lang = currentLanguage,
|
||||
prefsRepo = prefsRepo
|
||||
|
||||
@@ -39,15 +39,14 @@ fun SRToolScreen(
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val apiRepo = remember { GaCyApiRepository() }
|
||||
val serverConfig = remember { prefsRepo.getServerConfig() }
|
||||
|
||||
var commandInput by remember { mutableStateOf("") }
|
||||
var commandOutput by remember { mutableStateOf("") }
|
||||
var commandSuccess by remember { mutableStateOf(true) }
|
||||
var isExecuting by remember { mutableStateOf(false) }
|
||||
|
||||
val presetCommands = listOf(
|
||||
"/unstuck",
|
||||
"/give 200001 10000",
|
||||
"/avatar 1204 80",
|
||||
"/avatar 1213 80",
|
||||
"/avatar 1308 80",
|
||||
@@ -146,9 +145,10 @@ fun SRToolScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val res = apiRepo.sendUnstuck(serverConfig)
|
||||
val res = apiRepo.sendUnstuck(prefsRepo.getServerConfig())
|
||||
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
|
||||
commandOutput = "[UNSTUCK] ${res.message}"
|
||||
commandSuccess = res.success
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
|
||||
@@ -185,7 +185,7 @@ fun SRToolScreen(
|
||||
value = commandInput,
|
||||
onValueChange = { commandInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(tr(lang, "Enter GM command (e.g. /give 200001 100)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
|
||||
placeholder = { Text(tr(lang, "Enter GM command (e.g. /help)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -201,8 +201,9 @@ fun SRToolScreen(
|
||||
if (commandInput.isNotBlank()) {
|
||||
scope.launch {
|
||||
isExecuting = true
|
||||
val result = apiRepo.executeGmCommand(serverConfig, commandInput)
|
||||
val result = apiRepo.executeGmCommand(prefsRepo.getServerConfig(), commandInput)
|
||||
commandOutput = result.message
|
||||
commandSuccess = result.success
|
||||
isExecuting = false
|
||||
}
|
||||
}
|
||||
@@ -229,7 +230,7 @@ fun SRToolScreen(
|
||||
) {
|
||||
Text(
|
||||
text = commandOutput,
|
||||
color = TerminalGreen,
|
||||
color = if (commandSuccess) TerminalGreen else GaCyRed,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
|
||||
@@ -1,388 +1,41 @@
|
||||
package com.gacy.android.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gacy.android.data.PersistentRepository
|
||||
import com.gacy.android.data.PreferencesRepository
|
||||
import com.gacy.android.model.*
|
||||
import com.gacy.android.model.AppLanguage
|
||||
import com.gacy.android.model.ServerStatus
|
||||
import com.gacy.android.service.LogBuffer
|
||||
import com.gacy.android.service.ServerService
|
||||
import com.gacy.android.ui.components.*
|
||||
import com.gacy.android.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
import com.gacy.android.ui.components.TerminalConsole
|
||||
import com.gacy.android.ui.components.tr
|
||||
import com.gacy.android.ui.theme.BackgroundDark
|
||||
|
||||
@Composable
|
||||
fun ServerScreen(
|
||||
lang: AppLanguage,
|
||||
serverStatus: ServerStatus,
|
||||
persistentRepo: PersistentRepository,
|
||||
prefsRepo: PreferencesRepository
|
||||
) {
|
||||
fun ServerScreen(lang: AppLanguage, serverStatus: ServerStatus, prefsRepo: PreferencesRepository) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val logs by LogBuffer.logs.collectAsState()
|
||||
var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) }
|
||||
|
||||
var sendHotfix by remember { mutableStateOf(true) }
|
||||
var serverKey by remember { mutableStateOf("") }
|
||||
var tbConfig by remember { mutableStateOf(TrailblazerConfig()) }
|
||||
var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
|
||||
|
||||
// Dialog flags
|
||||
var showKeyDialog by remember { mutableStateOf(false) }
|
||||
var showTbDialog by remember { mutableStateOf(false) }
|
||||
var showExDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
sendHotfix = persistentRepo.getSendHotfix()
|
||||
serverKey = persistentRepo.getServerKey()
|
||||
tbConfig = persistentRepo.getTrailblazerConfig()
|
||||
exConfigs = persistentRepo.getExRankConfigs()
|
||||
val config = prefsRepo.getServerConfig()
|
||||
val active = serverStatus == ServerStatus.RUNNING || serverStatus == ServerStatus.STARTING
|
||||
Column(Modifier.fillMaxSize().background(BackgroundDark).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("GaCy Go — ${serverStatus.name}", style = MaterialTheme.typography.titleLarge)
|
||||
Text("SDK: ${config.dispatchPort} | Game: ${config.gameserverPort}")
|
||||
Text(tr(lang, "Set the host in Settings to the address the game uses to reach this device.", "ตั้งค่า Host ในหน้าตั้งค่าเป็น IP ที่เกมใช้เชื่อมต่ออุปกรณ์นี้", "在设置中填写游戏用于连接此设备的地址。"))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { if (active) ServerService.stop(context) else ServerService.start(context) }, enabled = serverStatus != ServerStatus.STOPPING) {
|
||||
Text(if (active) tr(lang, "Stop", "หยุด", "停止") else tr(lang, "Start", "เริ่ม", "启动"))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundDark)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Status Card
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = SurfaceDark,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, BorderDark, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = tr(lang, "GaCy Server Core", "คอร์เซิร์ฟเวอร์ GaCy", "GaCy 核心服务器"),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = tr(lang, "Ports: 21000 (Dispatch) / 23301 (Game)", "พอร์ต: 21000 (Dispatch) / 23301 (Game)", "端口: 21000 (调度) / 23301 (游戏)"),
|
||||
fontSize = 12.sp,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
StatusBadge(serverStatus, lang)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Control Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val isRunning = serverStatus == ServerStatus.RUNNING
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (isRunning) {
|
||||
ServerService.stop(context)
|
||||
} else {
|
||||
ServerService.start(context)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isRunning) GaCyRed else GaCyPurple
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
if (isRunning) tr(lang, "STOP", "หยุดเซิร์ฟเวอร์", "停止")
|
||||
else tr(lang, "START", "เริ่มเซิร์ฟเวอร์", "启动")
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { ServerService.restart(context) },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(tr(lang, "Restart", "รีสตาร์ต", "重启"))
|
||||
OutlinedButton(onClick = { ServerService.restart(context) }, enabled = serverStatus != ServerStatus.STARTING && serverStatus != ServerStatus.STOPPING) {
|
||||
Text(tr(lang, "Restart", "เริ่มใหม่", "重启"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal Console
|
||||
TerminalConsole(logs = logs, lang = lang)
|
||||
|
||||
// Server Configuration Tools
|
||||
Text(
|
||||
text = tr(lang, "Server & Character Tools", "เครื่องมือเซิร์ฟเวอร์และตัวละคร", "服务器与角色工具"),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.VpnKey,
|
||||
title = tr(lang, "Server Password / Key", "รหัสผ่านเซิร์ฟเวอร์ (Server Key)", "服务器密钥 / 密码"),
|
||||
subtitle = if (serverKey.isEmpty()) tr(lang, "No password set", "ไม่ได้ตั้งรหัสผ่าน", "未设置密码") else "Key: $serverKey",
|
||||
color = GaCyPurple
|
||||
) {
|
||||
showKeyDialog = true
|
||||
}
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Person,
|
||||
title = tr(lang, "Edit Trailblazer (Gender & Path)", "ปรับแต่งผู้บุกเบิก (เพศและ Path)", "开拓者设置 (性别与命途)"),
|
||||
subtitle = "${tbConfig.gender} | ${tbConfig.tbPath}",
|
||||
color = GaCyCyan
|
||||
) {
|
||||
showTbDialog = true
|
||||
}
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Star,
|
||||
title = tr(lang, "3 EX Eidolon (Rank) Manager", "จัดการ 3 EX Eidolon (สกิลพิเศษ)", "3 EX 星魂管理"),
|
||||
subtitle = exConfigs.filter { it.active }.joinToString(", ") { it.name }.ifEmpty { tr(lang, "None active", "ปิดอยู่ทั้งหมด", "全部未开启") },
|
||||
color = GaCyAmber
|
||||
) {
|
||||
showExDialog = true
|
||||
}
|
||||
|
||||
// Send Hotfix Toggle Card
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = CardDark,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(GaCyGreen.copy(alpha = 0.2f), RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.CloudSync, contentDescription = null, tint = GaCyGreen, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = tr(lang, "Send Hotfix", "ส่ง Hotfix อัตโนมัติ", "自动发送热更新"),
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = if (sendHotfix) tr(lang, "Online Assets Download", "ดาวน์โหลด Assets ออนไลน์", "在线下载资源")
|
||||
else tr(lang, "Offline Mode (Fix Blackscreen)", "โหมดออฟไลน์ (แก้จอดำ)", "离线模式 (修复黑屏)"),
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = sendHotfix,
|
||||
onCheckedChange = { checked ->
|
||||
sendHotfix = checked
|
||||
scope.launch {
|
||||
persistentRepo.setSendHotfix(checked)
|
||||
Toast.makeText(context, tr(lang, "Saved send_hotfix: $checked", "บันทึก send_hotfix แล้ว", "已保存 send_hotfix"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = TextPrimary,
|
||||
checkedTrackColor = GaCyGreen,
|
||||
uncheckedTrackColor = SurfaceDark
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
if (showKeyDialog) {
|
||||
var tempKey by remember { mutableStateOf(serverKey) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showKeyDialog = false },
|
||||
title = { Text(tr(lang, "Set Server Password", "กำหนดรหัสผ่านเซิร์ฟเวอร์", "设置服务器密码")) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = tempKey,
|
||||
onValueChange = { tempKey = it },
|
||||
label = { Text(tr(lang, "Password (Optional)", "รหัสผ่าน (เว้นว่างได้)", "密码 (可选)")) },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
serverKey = tempKey
|
||||
scope.launch {
|
||||
persistentRepo.setServerKey(tempKey)
|
||||
showKeyDialog = false
|
||||
Toast.makeText(context, tr(lang, "Server key saved", "บันทึกรหัสผ่านแล้ว", "密码已保存"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyPurple)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showKeyDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
|
||||
if (showTbDialog) {
|
||||
var gender by remember { mutableStateOf(tbConfig.gender) }
|
||||
var path by remember { mutableStateOf(tbConfig.tbPath) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTbDialog = false },
|
||||
title = { Text(tr(lang, "Trailblazer Settings", "ตั้งค่าตัวเอก (ผู้บุกเบิก)", "开拓者设置")) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(tr(lang, "Gender:", "เพศ:", "性别:"), color = TextSecondary, fontSize = 12.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
listOf("Female", "Male").forEach { g ->
|
||||
FilterChip(
|
||||
selected = gender == g,
|
||||
onClick = { gender = g },
|
||||
label = { Text(if (g == "Female") "Stelle (หญิง)" else "Caelus (ชาย)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tr(lang, "Path:", "เส้นทาง (Path):", "命途:"), color = TextSecondary, fontSize = 12.sp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf("Destruction", "Preservation", "Harmony", "Elation", "Remembrance").forEach { p ->
|
||||
FilterChip(
|
||||
selected = path == p,
|
||||
onClick = { path = p },
|
||||
label = { Text(p, fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val updated = tbConfig.copy(gender = gender, tbPath = path)
|
||||
tbConfig = updated
|
||||
scope.launch {
|
||||
persistentRepo.saveTrailblazerConfig(updated)
|
||||
showTbDialog = false
|
||||
Toast.makeText(context, tr(lang, "Trailblazer saved", "บันทึกตัวเอกแล้ว", "已保存开拓者"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyCyan)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTbDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
|
||||
if (showExDialog) {
|
||||
var currentList by remember { mutableStateOf(exConfigs) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExDialog = false },
|
||||
title = { Text(tr(lang, "3 EX Eidolon Config", "เปิด/ปิด 3 EX Eidolon", "3 EX 星魂设置")) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
currentList.forEachIndexed { index, cfg ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("${cfg.name} [ID: ${cfg.avatarId}]", color = TextPrimary, fontSize = 13.sp)
|
||||
Switch(
|
||||
checked = cfg.active,
|
||||
onCheckedChange = { active ->
|
||||
val updated = currentList.toMutableList()
|
||||
updated[index] = cfg.copy(active = active)
|
||||
currentList = updated
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = GaCyAmber)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
exConfigs = currentList
|
||||
scope.launch {
|
||||
persistentRepo.saveExRankConfigs(currentList)
|
||||
showExDialog = false
|
||||
Toast.makeText(context, tr(lang, "EX config saved", "บันทึก EX แล้ว", "已保存 EX 设置"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyAmber)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
RadioButton(
|
||||
selected = mode == m,
|
||||
onClick = { mode = m },
|
||||
onClick = { mode = m; pingStatus = null },
|
||||
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -112,7 +112,7 @@ fun SettingsScreen(
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
onValueChange = { host = it; pingStatus = null },
|
||||
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
@@ -125,7 +125,7 @@ fun SettingsScreen(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = dispatchPort,
|
||||
onValueChange = { dispatchPort = it },
|
||||
onValueChange = { dispatchPort = it; pingStatus = null },
|
||||
label = { Text("Dispatch Port") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
@@ -137,7 +137,7 @@ fun SettingsScreen(
|
||||
|
||||
OutlinedTextField(
|
||||
value = gameserverPort,
|
||||
onValueChange = { gameserverPort = it },
|
||||
onValueChange = { gameserverPort = it; pingStatus = null },
|
||||
label = { Text("Game Port") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
@@ -155,17 +155,15 @@ fun SettingsScreen(
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val dPort = dispatchPort.toIntOrNull() ?: 21000
|
||||
val gPort = gameserverPort.toIntOrNull() ?: 23301
|
||||
val newCfg = ServerConfig(
|
||||
executionMode = mode,
|
||||
remoteHost = host,
|
||||
dispatchPort = dPort,
|
||||
gameserverPort = gPort
|
||||
)
|
||||
val newCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
|
||||
if (newCfg == null) {
|
||||
Toast.makeText(context, tr(lang, "Enter a valid host and ports from 1 to 65535", "กรอก Host และพอร์ตตั้งแต่ 1 ถึง 65535 ให้ถูกต้อง", "请输入有效主机及 1 至 65535 的端口"), Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
config = newCfg
|
||||
host = newCfg.remoteHost
|
||||
prefsRepo.saveServerConfig(newCfg)
|
||||
Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, tr(lang, "Saved. Restart the server to apply changes.", "บันทึกแล้ว เริ่มเซิร์ฟเวอร์ใหม่เพื่อใช้ค่า", "已保存,重启服务器后生效。"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
@@ -174,18 +172,23 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
enabled = !isPinging,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val testCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
|
||||
if (testCfg == null) {
|
||||
pingStatus = tr(lang, "Invalid host or port", "Host หรือพอร์ตไม่ถูกต้อง", "主机或端口无效")
|
||||
return@launch
|
||||
}
|
||||
isPinging = true
|
||||
val dPort = dispatchPort.toIntOrNull() ?: 21000
|
||||
val gPort = gameserverPort.toIntOrNull() ?: 23301
|
||||
val testCfg = ServerConfig(mode, host, dPort, gPort)
|
||||
val (ok, duration) = apiRepo.checkServerHealth(testCfg)
|
||||
if (config.withNetworkInput(mode, host, dispatchPort, gameserverPort) == testCfg) {
|
||||
pingStatus = if (ok) {
|
||||
"Connected! Latency: ${duration}ms"
|
||||
} else {
|
||||
"Host unreachable or offline"
|
||||
}
|
||||
}
|
||||
isPinging = false
|
||||
}
|
||||
},
|
||||
@@ -228,7 +231,7 @@ fun SettingsScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "GaCy Android v1.0.0\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
|
||||
text = "GaCy Android ${com.gacy.android.BuildConfig.VERSION_NAME}\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.data.GoRuntimeInstaller
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class GoRuntimeInstallerTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun archive(resource: String, hotfix: String, extra: Pair<String, String>? = null, versions: String = "{}"): ByteArray {
|
||||
val entries = linkedMapOf(
|
||||
"persistent" to "original-save", "freesr-data.json" to "original-roster",
|
||||
"server.json" to "server-config", "hotfix.json" to hotfix, "versions.json" to versions,
|
||||
"res.json" to resource, "teleports.json" to "{}", "resources/MapEntrance.json" to "{}"
|
||||
)
|
||||
if (extra != null) entries[extra.first] = extra.second
|
||||
val output = ByteArrayOutputStream()
|
||||
ZipOutputStream(output).use { zip ->
|
||||
entries.forEach { (name, content) ->
|
||||
zip.putNextEntry(ZipEntry(name)); zip.write(content.toByteArray()); zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
@Test fun updatePreservesUserDataAndRepairsMissingResources() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val old = archive("old", """{"existing":{"asset":"bundled"}}""")
|
||||
GoRuntimeInstaller(directory) { ByteArrayInputStream(old) }.prepare()
|
||||
File(directory, "persistent").writeText("user-save")
|
||||
File(directory, "freesr-data.json").writeText("user-roster")
|
||||
File(directory, "hotfix.json").writeText("""{"existing":{"asset":"user"}}""")
|
||||
File(directory, "versions.json").writeText("""{"existing":{"asset":"user"}}""")
|
||||
val updated = archive("new", """{"existing":{"asset":"new"},"added":{"asset":"new"}}""", versions = """{"existing":{"asset":"new"},"added":{"asset":"new"}}""")
|
||||
val installer = GoRuntimeInstaller(directory) { ByteArrayInputStream(updated) }
|
||||
installer.prepare()
|
||||
assertEquals("user-save", File(directory, "persistent").readText())
|
||||
assertEquals("user-roster", File(directory, "freesr-data.json").readText())
|
||||
assertEquals("new", File(directory, "res.json").readText())
|
||||
val hotfix = JSONObject(File(directory, "hotfix.json").readText())
|
||||
assertEquals("user", hotfix.getJSONObject("existing").getString("asset"))
|
||||
assertEquals("new", hotfix.getJSONObject("added").getString("asset"))
|
||||
val versions = JSONObject(File(directory, "versions.json").readText())
|
||||
assertEquals("user", versions.getJSONObject("existing").getString("asset"))
|
||||
assertEquals("new", versions.getJSONObject("added").getString("asset"))
|
||||
File(directory, "resources/MapEntrance.json").delete()
|
||||
installer.prepare()
|
||||
assertTrue(File(directory, "resources/MapEntrance.json").isFile)
|
||||
assertEquals("user-save", File(directory, "persistent").readText())
|
||||
}
|
||||
|
||||
@Test fun rejectsPathsOutsideRuntimeDirectory() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val zip = archive("data", "{}", "../outside" to "bad")
|
||||
try {
|
||||
GoRuntimeInstaller(directory) { ByteArrayInputStream(zip) }.prepare()
|
||||
fail("Path traversal should be rejected")
|
||||
} catch (_: IllegalArgumentException) { }
|
||||
assertFalse(File(directory.parentFile, "outside").exists())
|
||||
assertFalse(File(directory, ".asset-version").exists())
|
||||
}
|
||||
|
||||
@Test fun installerUpgradeMergesVersionsEvenWhenArchiveIsUnchanged() {
|
||||
val directory = tmp.newFolder("migration")
|
||||
val zip = archive("data", "{}", versions = """{"new":{"asset":"bundle"}}""")
|
||||
val installer = GoRuntimeInstaller(directory) { ByteArrayInputStream(zip) }
|
||||
installer.prepare()
|
||||
File(directory, "versions.json").writeText("""{"custom":{"asset":"user"}}""")
|
||||
val marker = File(directory, ".asset-version")
|
||||
marker.writeText(marker.readText().removePrefix("2:"))
|
||||
installer.prepare()
|
||||
val versions = JSONObject(File(directory, "versions.json").readText())
|
||||
assertEquals("user", versions.getJSONObject("custom").getString("asset"))
|
||||
assertEquals("bundle", versions.getJSONObject("new").getString("asset"))
|
||||
}
|
||||
|
||||
@Test fun simultaneousStartsSerializeExtraction() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val zip = archive("data", "{}")
|
||||
val firstOpened = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val secondOpened = CountDownLatch(1)
|
||||
val opens = AtomicInteger()
|
||||
val installer = GoRuntimeInstaller(directory) {
|
||||
if (opens.incrementAndGet() == 1) {
|
||||
firstOpened.countDown()
|
||||
check(release.await(5, TimeUnit.SECONDS))
|
||||
} else {
|
||||
secondOpened.countDown()
|
||||
}
|
||||
ByteArrayInputStream(zip)
|
||||
}
|
||||
val workers = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val first = workers.submit { installer.prepare() }
|
||||
assertTrue(firstOpened.await(5, TimeUnit.SECONDS))
|
||||
val second = workers.submit { installer.prepare() }
|
||||
assertFalse("a second install entered before the first was released", secondOpened.await(200, TimeUnit.MILLISECONDS))
|
||||
release.countDown()
|
||||
first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS)
|
||||
assertEquals("data", File(directory, "res.json").readText())
|
||||
} finally {
|
||||
release.countDown()
|
||||
workers.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.service.LogBuffer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogBufferTest {
|
||||
@After fun cleanup() = LogBuffer.clear()
|
||||
|
||||
@Test fun preservesBoundedDiagnosticsWhenRecentLogRollsOver() {
|
||||
LogBuffer.clear()
|
||||
repeat(101) { LogBuffer.append("[gateway] diagnostic-$it") }
|
||||
repeat(501) { LogBuffer.append("ordinary-$it") }
|
||||
val copied = LogBuffer.getAllText()
|
||||
assertFalse(copied.contains("diagnostic-0\n"))
|
||||
assertTrue(copied.contains("diagnostic-1\n"))
|
||||
assertTrue(copied.contains("ordinary-500"))
|
||||
LogBuffer.clear()
|
||||
assertTrue(LogBuffer.getAllText().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.model.SceneConfig
|
||||
import com.gacy.android.model.ServerConfig
|
||||
import com.gacy.android.model.ServerExecutionMode
|
||||
import com.gacy.android.model.TrailblazerConfig
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ModelAndConfigTest {
|
||||
|
||||
@Test
|
||||
fun networkSettingsRejectInvalidAddressesAndPorts() {
|
||||
val original = ServerConfig(serverKey = "keep")
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "0.0.0.0", "21000", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com/path", "21000", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com", "65536", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com", "abc", "23301"))
|
||||
val valid = original.withNetworkInput(ServerExecutionMode.REMOTE_SERVER, " example.com ", "1234", "5678")!!
|
||||
assertEquals("example.com", valid.remoteHost)
|
||||
assertEquals(1234, valid.dispatchPort)
|
||||
assertEquals("keep", valid.serverKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localApiUsesLoopbackWhileRemoteApiUsesConfiguredHost() {
|
||||
val local = ServerConfig(remoteHost = "192.168.1.42", dispatchPort = 24567)
|
||||
assertEquals("http://127.0.0.1:24567", local.dispatchUrl)
|
||||
assertEquals("http://192.168.1.42:24567", local.copy(executionMode = ServerExecutionMode.REMOTE_SERVER).dispatchUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSceneConfigSerialization() {
|
||||
val config = SceneConfig(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.data.parseUnstuckResponse
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UnstuckResponseTest {
|
||||
@Test fun confirmsOnlyQueuedRequestsWithAnId() {
|
||||
assertTrue(parseUnstuckResponse("""{"state":"queued","request_id":"abc"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"retcode":0,"message":"OK"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"queued"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"queued","request_id":null}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"failed","request_id":"abc"}""").success)
|
||||
assertFalse(parseUnstuckResponse("not json").success)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Build GaCy Go for Android and package its runtime data.
|
||||
|
||||
Usage: python tools/package_go_runtime.py PATH_TO_GAC_GO
|
||||
The source checkout is read only; its current source/resources are packaged.
|
||||
Mutable saves are seeded from the checkout's committed defaults, never from
|
||||
the operator's live working files.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = Path(sys.argv[1]).resolve()
|
||||
if not (source / "go.mod").is_file():
|
||||
raise SystemExit("Expected a GaC-hkrpg-go checkout")
|
||||
|
||||
native = root / "app/src/main/jniLibs/arm64-v8a"
|
||||
native.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env.update(GOOS="android", GOARCH="arm64", CGO_ENABLED="0")
|
||||
for name in ("sdkserver", "gameserver"):
|
||||
subprocess.run(["go", "build", "-o", str(native / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||
cwd=source, env=env, check=True)
|
||||
|
||||
# Optional emulator-only binaries use Linux syscalls on the x86_64 emulator.
|
||||
# They are excluded from ordinary ARM64 builds.
|
||||
if "--emulator" in sys.argv[2:]:
|
||||
emulator = root / "app/src/debug/jniLibs/x86_64"
|
||||
emulator.mkdir(parents=True, exist_ok=True)
|
||||
env.update(GOOS="linux", GOARCH="amd64", CGO_ENABLED="0")
|
||||
for name in ("sdkserver", "gameserver"):
|
||||
subprocess.run(["go", "build", "-o", str(emulator / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||
cwd=source, env=env, check=True)
|
||||
|
||||
archive = root / "app/src/goAssets/gacy-go-data.zip"
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
resources = ("server.json", "hotfix.json", "versions.json", "res.json",
|
||||
"teleports.json", "challenge_data.json", "mission_unlocks.json")
|
||||
scripts = ("damage_analyst_live.lua", "fps_settings_120.lua", "freecam.lua",
|
||||
"hide_ui.lua", "navigation_profiles.lua", "ping_rainbow.lua")
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
||||
for name in resources:
|
||||
z.write(source / name, name)
|
||||
for path in (source / "resources").rglob("*"):
|
||||
if path.is_file():
|
||||
z.write(path, path.relative_to(source).as_posix())
|
||||
for name in scripts:
|
||||
z.write(source / "scripts" / name, "scripts/" + name)
|
||||
for name in ("persistent", "freesr-data.json"):
|
||||
data = subprocess.check_output(["git", "show", f"HEAD:{name}"], cwd=source)
|
||||
z.writestr(name, data)
|
||||
print(f"Packaged Go runtime: {archive}")
|
||||
Reference in new issue
Block a user