Dev #1

Merged
GaCy merged 2 commits from Dev into main 2026-11-02 02:50:39 +07:00
22 changed files with 955 additions and 799 deletions

No files matched your search

+3
View File
@@ -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
+56 -25
View File
@@ -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">
@@ -11,7 +56,7 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
[![Target ABI](https://img.shields.io/badge/ABI-arm64--v8a-blue?style=flat)](https://developer.android.com/ndk)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[English](#-english) | [ภาษาไทย](#-ภาษาไทย)
[ภาษาไทย](README.md) | [English](README_EN.md) | [简体中文](README_ZH.md)
</div>
@@ -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
+67
View File
@@ -0,0 +1,67 @@
# GaCy Android
[ภาษาไทย](README.md) | English | [简体中文](README_ZH.md)
GaCy Android is an Android companion and local server manager for the
GaC-hkrpg-go project. The
port is in progress: the app starts the Go SDK and game servers in a foreground
service, or connects to an existing server.
## Current status
- An ARM64 debug build was tested on a physical phone: the user confirmed game
login, in-game map teleport, and restarting the server after an update.
- The Go SDK, shared-data, game-server and KCP tests passed. These checks do not
establish that every game feature works on Android.
- Character settings from the older ppSR runtime are not yet connected to the
Go runtime. `/give` and `/heal` are not implemented by the Go command API.
## Features
- Start, stop and restart the local server; connect to a remote server and
monitor its SDK health.
- View and copy server logs, send GM commands and open the SRTool web app.
- Use the game's own map to teleport. There is no separate teleport page in
the app navigation.
- Submit an Unstuck request. The app reports it as queued only when the server
supplies a request ID; this does not confirm the game has processed it.
- Configure the host and ports and display the actual app version in Settings.
`/max` updates avatar level, promotion and rank while preserving other save
fields. `/teleport <plane_id> [floor_id]` saves the scene for the next game
connection. Both have automated tests but have not been verified in the game.
`/clear lineup` is rejected because the active team cannot be empty.
## Build
Requirements: Android SDK 35, JDK 17 or 21, Go, Python, and a local checkout of
GaC-hkrpg-go. The app requires Android 8.0 (API 26) or newer. Normal debug builds
target ARM64.
```powershell
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
.\gradlew.bat testDebugUnitTest assembleDebug
```
The packaging script uses the current Go source and resources, and seeds
player data from committed defaults. The APK is written to
`app/build/outputs/apk/debug/app-debug.apk` and copied to the project root as
`GaCy-Android-<versionName>-debug.apk`. APK files are ignored by Git.
For the x86_64 emulator, use `--emulator` when packaging and add
`-PgacyEmulator` to the Gradle command. These test executables are not part of
the normal ARM64 build.
## Runtime and network configuration
The Go runtime stores its files under the app's private `files/gacy-go`
directory, separate from old ppSR data. An update preserves player data and
existing hotfix entries, while adding new bundled versions. A completely empty
cached version does not override available bundled hotfix data.
Set the Host in Settings to the address the game client uses to reach the
phone. Use `127.0.0.1` when both run on the same device. Configure the SDK and
game ports as needed; changes apply after restarting the server. Redirecting
the game client's dispatch traffic remains a separate setup step. The Settings
connection test checks the values currently entered; remote monitoring reports
an error after three consecutive failed SDK health checks.
+57
View File
@@ -0,0 +1,57 @@
# GaCy Android
[ภาษาไทย](README.md) | [English](README_EN.md) | 简体中文
GaCy Android 是 GaC-hkrpg-go
的 Android 辅助应用和本地服务器管理器。移植仍在进行中:应用可通过前台服务启动
Go SDK 和游戏服务器,也可连接已有的远程服务器。
## 当前状态
- 已在实体手机上测试 ARM64 调试版:用户确认可以登录游戏、使用游戏内地图传送,
以及在更新后重新启动服务器。
- Go SDK、共享数据、游戏服务器和 KCP 测试已通过;这不代表所有游戏功能均已在
Android 客户端验证。
- 旧版 ppSR 的角色设置尚未接入 Go 运行时。Go 命令 API 尚不支持 `/give` 和 `/heal`。
## 功能
- 启动、停止和重启本地服务器;连接远程服务器并监控 SDK 健康状态。
- 查看和复制服务器日志、发送 GM 命令,以及打开 SRTool 网页。
- 使用游戏内地图传送;应用导航中没有单独的传送页面。
- 提交 Unstuck 请求。只有服务器返回请求 ID 时,应用才显示请求已排队;
这不表示游戏已经处理成功。
- 在设置中配置主机和端口,并显示应用的实际版本。
`/max` 修改角色等级、晋阶和星魂,同时保留存档的其他字段。
`/teleport <plane_id> [floor_id]` 保存下次连接时使用的场景。
两者已通过自动测试,但尚未在游戏内验证。由于当前队伍不能为空,
`/clear lineup` 会被拒绝。
## 构建
需要 Android SDK 35、JDK 17 或 21、Go、Python,以及本地 GaC-hkrpg-go 源码。
应用最低支持 Android 8.0(API 26);普通调试版本面向 ARM64。
```powershell
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
.\gradlew.bat testDebugUnitTest assembleDebug
```
打包脚本使用当前 Go 源码和资源,并从已提交的默认文件初始化玩家数据。
APK 输出至 `app/build/outputs/apk/debug/app-debug.apk`,同时复制到项目根目录,
文件名为 `GaCy-Android-<versionName>-debug.apk`。APK 已被 Git 忽略。
如果使用 x86_64 模拟器,请在打包时添加 `--emulator`,并在 Gradle 命令中添加
`-PgacyEmulator`。模拟器测试程序不包含在普通 ARM64 构建中。
## 运行时与网络配置
Go 运行时数据保存在应用私有目录 `files/gacy-go`,与旧版 ppSR 数据分开。
更新时保留玩家数据和已有 hotfix 条目,同时加入新版本。若之前查询失败留下完全为空的
缓存条目,SDK 会优先使用可用的内置 hotfix 数据。
在设置中将 Host 填为游戏客户端连接手机所使用的地址;两者在同一台设备上运行时可使用
`127.0.0.1`。按需配置 SDK 和游戏端口,修改后重启服务器生效。游戏客户端的 dispatch
流量重定向仍需单独设置。连接测试使用当前输入的值;远程模式在 SDK 健康检查连续三次
失败后报告错误。
+38 -5
View File
@@ -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,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<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)
}
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)")
}
@@ -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()
_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> = _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()
}
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
)
}
}
@@ -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
@@ -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)
}
}
+54
View File
@@ -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}")