From 3c1186c78134ad884afc6b22d98da77cad8e2078 Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Mon, 2 Nov 2026 02:39:37 +0700 Subject: [PATCH] Port Android server runtime to GaCy Go --- .gitignore | 3 + README.md | 79 ++- app/build.gradle.kts | 43 +- .../java/com/gacy/android/CrashTracker.kt | 84 ++++ .../java/com/gacy/android/GaCyApplication.kt | 12 +- .../java/com/gacy/android/MainActivity.kt | 92 ++++ .../gacy/android/data/GaCyApiRepository.kt | 48 +- .../gacy/android/data/GoRuntimeRepository.kt | 86 ++++ .../com/gacy/android/model/ServerModels.kt | 16 +- .../com/gacy/android/service/LogBuffer.kt | 17 +- .../com/gacy/android/service/ServerService.kt | 458 +++++------------- .../com/gacy/android/ui/screens/MainScreen.kt | 9 - .../gacy/android/ui/screens/SRToolScreen.kt | 13 +- .../gacy/android/ui/screens/ServerScreen.kt | 383 +-------------- .../gacy/android/ui/screens/SettingsScreen.kt | 49 +- .../gacy/android/GoRuntimeInstallerTest.kt | 119 +++++ .../java/com/gacy/android/LogBufferTest.kt | 23 + .../com/gacy/android/ModelAndConfigTest.kt | 23 + .../com/gacy/android/UnstuckResponseTest.kt | 17 + tools/package_go_runtime.py | 54 +++ 20 files changed, 830 insertions(+), 798 deletions(-) create mode 100644 app/src/main/java/com/gacy/android/CrashTracker.kt create mode 100644 app/src/main/java/com/gacy/android/data/GoRuntimeRepository.kt create mode 100644 app/src/test/java/com/gacy/android/GoRuntimeInstallerTest.kt create mode 100644 app/src/test/java/com/gacy/android/LogBufferTest.kt create mode 100644 app/src/test/java/com/gacy/android/UnstuckResponseTest.kt create mode 100644 tools/package_go_runtime.py diff --git a/.gitignore b/.gitignore index 26b27d3..9d7f84f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 58cb31d..3cdacbb 100644 --- a/README.md +++ b/README.md @@ -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 +.\gradlew.bat testDebugUnitTest assembleDebug +``` + +After a successful debug build, Gradle also copies the APK to the project root +as `GaCy-Android--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.
@@ -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 [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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36053b3..b3c7309 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 " + } + } +} + +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) +} diff --git a/app/src/main/java/com/gacy/android/CrashTracker.kt b/app/src/main/java/com/gacy/android/CrashTracker.kt new file mode 100644 index 0000000..e5ad377 --- /dev/null +++ b/app/src/main/java/com/gacy/android/CrashTracker.kt @@ -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) {} + } +} diff --git a/app/src/main/java/com/gacy/android/GaCyApplication.kt b/app/src/main/java/com/gacy/android/GaCyApplication.kt index dba6b4f..3539a07 100644 --- a/app/src/main/java/com/gacy/android/GaCyApplication.kt +++ b/app/src/main/java/com/gacy/android/GaCyApplication.kt @@ -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() { diff --git a/app/src/main/java/com/gacy/android/MainActivity.kt b/app/src/main/java/com/gacy/android/MainActivity.kt index 4c5fb30..88f99e8 100644 --- a/app/src/main/java/com/gacy/android/MainActivity.kt +++ b/app/src/main/java/com/gacy/android/MainActivity.kt @@ -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") + } + } + ) + } } } } diff --git a/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt b/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt index f3ef6cf..fe49a6d 100644 --- a/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt +++ b/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt @@ -9,40 +9,42 @@ 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 = 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) - } + Pair(false, -1L) } } @@ -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)") } diff --git a/app/src/main/java/com/gacy/android/data/GoRuntimeRepository.kt b/app/src/main/java/com/gacy/android/data/GoRuntimeRepository.kt new file mode 100644 index 0000000..81c2547 --- /dev/null +++ b/app/src/main/java/com/gacy/android/data/GoRuntimeRepository.kt @@ -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) + } +} diff --git a/app/src/main/java/com/gacy/android/model/ServerModels.kt b/app/src/main/java/com/gacy/android/model/ServerModels.kt index b58bea7..ef589ab 100644 --- a/app/src/main/java/com/gacy/android/model/ServerModels.kt +++ b/app/src/main/java/com/gacy/android/model/ServerModels.kt @@ -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( diff --git a/app/src/main/java/com/gacy/android/service/LogBuffer.kt b/app/src/main/java/com/gacy/android/service/LogBuffer.kt index e019290..4f03207 100644 --- a/app/src/main/java/com/gacy/android/service/LogBuffer.kt +++ b/app/src/main/java/com/gacy/android/service/LogBuffer.kt @@ -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() private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _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 } diff --git a/app/src/main/java/com/gacy/android/service/ServerService.kt b/app/src/main/java/com/gacy/android/service/ServerService.kt index 5a3dc96..8f87269 100644 --- a/app/src/main/java/com/gacy/android/service/ServerService.kt +++ b/app/src/main/java/com/gacy/android/service/ServerService.kt @@ -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() + private val lock = Any() + @Volatile private var generation = 0 override fun onBind(intent: Intent?): IBinder? = null - override fun onCreate() { - super.onCreate() - createNotificationChannel() - _serverStatus.value = ServerStatus.STOPPED - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val action = intent?.action ?: ACTION_START - - when (action) { + when (intent?.action ?: ACTION_START) { ACTION_START -> startServer() ACTION_STOP -> stopServer() - ACTION_RESTART -> restartServer() + ACTION_RESTART -> { stopServer(false); startServer() } } - return START_NOT_STICKY } private fun startServer() { - if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) { - return - } - - try { - val prefs = PreferencesRepository(this) - val config = prefs.getServerConfig() - - _serverStatus.value = ServerStatus.STARTING - startForegroundCompat("GaCy Server is starting...") - LogBuffer.append("--- GaCy Server Starting ---") - LogBuffer.append("Mode: ${config.executionMode.displayNameEn}") - LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}") - - if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) { - startLocalServerExecution(config.dispatchPort) - } else { - startRemoteMonitor(config.remoteHost, config.dispatchPort) - } - } catch (t: Throwable) { - LogBuffer.append("[Start Error] ${t.localizedMessage ?: t.message}") - t.printStackTrace() - _serverStatus.value = ServerStatus.ERROR - stopForegroundCompat() - stopSelf() - } - } - - private fun startLocalServerExecution(dispatchPort: Int) { - executionJob?.cancel() - executionJob = serviceScope.launch { + if (_serverStatus.value == ServerStatus.STARTING || _serverStatus.value == ServerStatus.RUNNING) return + val run = ++generation + val config = PreferencesRepository(this).getServerConfig() + _serverStatus.value = ServerStatus.STARTING + startForegroundCompat("Starting GaCy Go server") + job = scope.launch { try { - val updateRepo = UpdateRepository(this@ServerService) - if (!updateRepo.isServerInstalled()) { - LogBuffer.append("[Setup] Installing GaCy embedded server binaries from assets...") - val success = updateRepo.ensureServerInstalled() - if (!success) { - LogBuffer.append("[Error] Failed to install server binaries from assets.") - _serverStatus.value = ServerStatus.ERROR - stopForegroundCompat() - stopSelf() - return@launch + if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) { + val runtime = GoRuntimeRepository(applicationContext) + LogBuffer.append("[Setup] Preparing GaCy Go runtime") + runtime.prepare() + ensureActive() + launchProcess("gameserver", runtime.directory, config, run) + launchProcess("sdkserver", runtime.directory, config, run) + } + val api = GaCyApiRepository() + var ready = false + repeat(40) { + if (!ready) { + ensureActive() + if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) checkProcesses() + ready = api.checkServerHealth(config).first + if (!ready) delay(500) } - LogBuffer.append("[Setup] Server binaries installed successfully.") } - - val ppsrFile = File(filesDir, "server/build/build/ppsr.pyc") - if (!ppsrFile.exists()) { - LogBuffer.append("[Error] Server bytecode missing at: ${ppsrFile.absolutePath}") - _serverStatus.value = ServerStatus.ERROR - stopForegroundCompat() - stopSelf() - return@launch - } - - try { - val persistentRepo = PersistentRepository(this@ServerService) - persistentRepo.ensureValidPersistentJsonSync() - } catch (e: Throwable) { - LogBuffer.append("[Warning] Persistent validation: ${e.message}") - } - - LogBuffer.append("[System] Launching embedded GaCy Python server...") - - // Background TCP port readiness probe - probeJob?.cancel() - probeJob = serviceScope.launch { - var attempts = 0 - while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 35) { + if (!ready) error("SDK server did not respond") + if (run != generation) return@launch + _serverStatus.value = ServerStatus.RUNNING + updateNotification("GaCy Go server is running") + LogBuffer.append("[System] SDK server is ready at ${config.dispatchUrl}") + if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) { + while (isActive && run == generation) { delay(1000) - attempts++ - if (isPortOpen("127.0.0.1", dispatchPort)) { - _serverStatus.value = ServerStatus.RUNNING - updateNotification("GaCy Server is RUNNING on :$dispatchPort") - LogBuffer.append("[System] GaCy Dispatch server is ready on 127.0.0.1:$dispatchPort") - break - } + checkProcesses() + } + } else { + var failures = 0 + while (isActive && run == generation) { + delay(5000) + val healthy = api.checkServerHealth(config).first + ensureActive() + failures = if (healthy) 0 else failures + 1 + if (failures >= 3) error("Remote SDK server stopped responding") } } - - serverThread = Thread({ - try { - if (!Python.isStarted()) { - Python.start(AndroidPlatform(applicationContext)) - } - val py = Python.getInstance() - val runPpsr = py.getModule("run_ppsr") - val logReceiver = ServerLogReceiver() - runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver) - } catch (t: Throwable) { - LogBuffer.append("[Python Error] ${t.localizedMessage ?: t.message}") - t.printStackTrace() - } finally { - probeJob?.cancel() - _serverStatus.value = ServerStatus.STOPPED - LogBuffer.append("[System] Server stopped.") - stopForegroundCompat() - stopSelf() - } - }, "GaCy-PythonServer").apply { - start() + } catch (_: CancellationException) { + } catch (e: Exception) { + if (run == generation) { + LogBuffer.append("[Server error] ${e.message}") + terminateProcesses() + _serverStatus.value = ServerStatus.ERROR + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() } - } catch (t: Throwable) { - LogBuffer.append("[Execution Error] ${t.localizedMessage ?: t.message}") - t.printStackTrace() - _serverStatus.value = ServerStatus.ERROR - stopForegroundCompat() - stopSelf() } } } - private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) { - executionJob?.cancel() - executionJob = serviceScope.launch { - LogBuffer.append("[Remote] Connecting to remote GaCy host at $remoteHost:$dispatchPort...") - delay(600) - _serverStatus.value = ServerStatus.RUNNING - updateNotification("Monitoring Remote GaCy Host at $remoteHost") - LogBuffer.append("[Remote] Connected to remote GaCy server monitor.") + private fun checkProcesses() { + val failed = synchronized(lock) { processes.firstOrNull { !it.isAlive } } + if (failed != null) error("A Go server process exited with code ${failed.exitValue()}; see the log") + } + + private fun launchProcess(name: String, directory: File, config: ServerConfig, run: Int) { + val executable = File(applicationInfo.nativeLibraryDir, "libgacy_${name}.so") + check(executable.isFile) { "Missing Go runtime: ${executable.name}" } + val builder = ProcessBuilder(executable.absolutePath).directory(directory).redirectErrorStream(true) + builder.environment().apply { + put("GAC_SDK_PORT", config.dispatchPort.toString()) + put("GAC_GAME_PORT", config.gameserverPort.toString()) + put("GAC_PUBLIC_GAME_HOST", config.remoteHost) + put("GAC_PUBLIC_SDK_URL", "http://${config.remoteHost}:${config.dispatchPort}") + } + val process = builder.start() + synchronized(lock) { + if (run != generation) { process.destroy(); throw CancellationException() } + processes.add(process) + } + scope.launch { + try { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { LogBuffer.append("[$name] $it") } + } + } catch (e: java.io.IOException) { + if (process.isAlive && run == generation) LogBuffer.append("[$name] Log stream error: ${e.message}") + } } } - private fun stopServer() { - LogBuffer.append("--- GaCy Server Stopping ---") + private fun terminateProcesses() { + val old = synchronized(lock) { processes.toList().also { processes.clear() } } + old.forEach { it.destroy() } + old.forEach { if (!it.waitFor(2, TimeUnit.SECONDS)) it.destroyForcibly() } + } + + private fun stopServer(stopService: Boolean = true) { + ++generation + job?.cancel() + job = null _serverStatus.value = ServerStatus.STOPPING - updateNotification("Stopping GaCy Server...") - - probeJob?.cancel() - probeJob = null - - executionJob?.cancel() - executionJob = null - - serviceScope.launch { - try { - if (Python.isStarted()) { - val py = Python.getInstance() - val runPpsr = py.getModule("run_ppsr") - runPpsr.callAttr("kill_server_process") - } - } catch (e: Throwable) { - LogBuffer.append("[Stop Error] ${e.localizedMessage ?: e.message}") - } - - delay(1200) - serverThread?.interrupt() - serverThread = null - _serverStatus.value = ServerStatus.STOPPED - stopForegroundCompat() - stopSelf() - } - } - - private fun restartServer() { - LogBuffer.append("Restarting GaCy Server...") - stopServer() - serviceScope.launch { - delay(1500) - startServer() - } - } - - private fun isPortOpen(host: String, port: Int): Boolean { - return try { - Socket().use { socket -> - socket.connect(InetSocketAddress(host, port), 400) - true - } - } catch (_: Throwable) { - false - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - GaCyApplication.CHANNEL_ID, - GaCyApplication.CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Background service for GaCy Game Server & Proxy" - setShowBadge(false) - } - val manager = getSystemService(NotificationManager::class.java) - manager?.createNotificationChannel(channel) - } - } - - private fun createNotification(contentText: String): Notification { - createNotificationChannel() - - val launchIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val pendingIntent = PendingIntent.getActivity( - this, - 0, - launchIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val stopIntent = Intent(this, ServerService::class.java).apply { - action = ACTION_STOP - } - val stopPendingIntent = PendingIntent.getService( - this, - 1, - stopIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID) - .setContentTitle("GaCy Android") - .setContentText(contentText) - .setSmallIcon(R.drawable.app_icon) - .setOngoing(true) - .setContentIntent(pendingIntent) - .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent) - .build() - } - - private fun startForegroundCompat(text: String) { - try { - createNotificationChannel() - val notification = createNotification(text) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+ - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - ) - } else { - // API 26..33 - startForeground(NOTIFICATION_ID, notification) - } - } catch (e: Throwable) { - try { - val notification = createNotification(text) - startForeground(NOTIFICATION_ID, notification) - } catch (e2: Throwable) { - LogBuffer.append("[Foreground Warning] ${e2.message}") - } - } - } - - private fun updateNotification(contentText: String) { - try { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager - manager?.notify(NOTIFICATION_ID, createNotification(contentText)) - } catch (_: Throwable) {} - } - - private fun stopForegroundCompat() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - @Suppress("DEPRECATION") - stopForeground(true) - } - } catch (_: Throwable) {} - } - - override fun onDestroy() { - probeJob?.cancel() - serverThread?.interrupt() - executionJob?.cancel() - super.onDestroy() + terminateProcesses() _serverStatus.value = ServerStatus.STOPPED + LogBuffer.append("[System] GaCy server stopped") + if (stopService) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } + } + + private fun notification(text: String): Notification { + val channel = NotificationChannel(GaCyApplication.CHANNEL_ID, GaCyApplication.CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + val pending = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID).setContentTitle("GaCy Android").setContentText(text) + .setSmallIcon(R.drawable.app_icon).setContentIntent(pending).setOngoing(true).build() + } + private fun startForegroundCompat(text: String) { + val n = notification(text) + if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + else startForeground(NOTIFICATION_ID, n) + } + private fun updateNotification(text: String) { getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text)) } + override fun onDestroy() { + ++generation + job?.cancel() + terminateProcesses() + scope.cancel() + if (_serverStatus.value != ServerStatus.ERROR) _serverStatus.value = ServerStatus.STOPPED + super.onDestroy() } companion object { @@ -346,51 +179,10 @@ class ServerService : Service() { const val ACTION_STOP = "com.gacy.android.ACTION_STOP" const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART" private const val NOTIFICATION_ID = 20261 - private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED) val serverStatus: StateFlow = _serverStatus.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)) } } } diff --git a/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt index 48aed27..e5327c1 100644 --- a/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt @@ -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 diff --git a/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt index f15bb56..c4915c9 100644 --- a/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt +++ b/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt @@ -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) diff --git a/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt index 6d4e98e..90b21c6 100644 --- a/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt +++ b/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt @@ -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() - } - - 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", "รีสตาร์ต", "重启")) - } - } + 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", "เริ่ม", "启动")) + } + 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 - ) } } diff --git a/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt index 8810f1b..264f669 100644 --- a/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt @@ -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 - ) - config = newCfg - prefsRepo.saveServerConfig(newCfg) - Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show() + 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, "Saved. Restart the server to apply changes.", "บันทึกแล้ว เริ่มเซิร์ฟเวอร์ใหม่เพื่อใช้ค่า", "已保存,重启服务器后生效。"), Toast.LENGTH_SHORT).show() + } }, colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple), shape = RoundedCornerShape(8.dp) @@ -174,17 +172,22 @@ 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) - pingStatus = if (ok) { - "Connected! Latency: ${duration}ms" - } else { - "Host unreachable or offline" + 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 diff --git a/app/src/test/java/com/gacy/android/GoRuntimeInstallerTest.kt b/app/src/test/java/com/gacy/android/GoRuntimeInstallerTest.kt new file mode 100644 index 0000000..90c900a --- /dev/null +++ b/app/src/test/java/com/gacy/android/GoRuntimeInstallerTest.kt @@ -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? = 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() + } + } +} diff --git a/app/src/test/java/com/gacy/android/LogBufferTest.kt b/app/src/test/java/com/gacy/android/LogBufferTest.kt new file mode 100644 index 0000000..41fa182 --- /dev/null +++ b/app/src/test/java/com/gacy/android/LogBufferTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt b/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt index e6a97fe..622d8e1 100644 --- a/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt +++ b/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt @@ -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( diff --git a/app/src/test/java/com/gacy/android/UnstuckResponseTest.kt b/app/src/test/java/com/gacy/android/UnstuckResponseTest.kt new file mode 100644 index 0000000..7e9c330 --- /dev/null +++ b/app/src/test/java/com/gacy/android/UnstuckResponseTest.kt @@ -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) + } +} diff --git a/tools/package_go_runtime.py b/tools/package_go_runtime.py new file mode 100644 index 0000000..b57ec26 --- /dev/null +++ b/tools/package_go_runtime.py @@ -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}")