Dev #1
No files matched your search
@@ -19,3 +19,6 @@ app/build/
|
|||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
app/src/main/jniLibs/arm64-v8a/libgacy_*.so
|
||||||
|
app/src/goAssets/gacy-go-data.zip
|
||||||
|
app/src/debug/jniLibs/x86_64/libgacy_*.so
|
||||||
@@ -1,4 +1,49 @@
|
|||||||
# 📱 GaCy Android (if you have client and this ps is working pls tell me)
|
# 📱 GaCy Android
|
||||||
|
|
||||||
|
> **Go port in progress:** the local server now launches the SDK and game
|
||||||
|
> processes built from GaC-hkrpg-go. Teleporting uses the game's own map;
|
||||||
|
> the older ppSR character settings are not yet connected. On 1 November 2026,
|
||||||
|
> the user confirmed that an ARM64 debug build could enter the game and teleport
|
||||||
|
> using the in-game map on the same phone. On 2 November, the user confirmed
|
||||||
|
> that the updated build started the server, entered the game and restarted the
|
||||||
|
> server successfully. Other features still need validation.
|
||||||
|
|
||||||
|
The Go SDK, shared-data, game-server and KCP package tests have also passed;
|
||||||
|
these automated tests do not establish that every gameplay feature works on
|
||||||
|
the Android client.
|
||||||
|
|
||||||
|
To build the embedded runtime, use Go and Python with a local checkout of
|
||||||
|
GaC-hkrpg-go, then build the app:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
|
||||||
|
.\gradlew.bat testDebugUnitTest assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
After a successful debug build, Gradle also copies the APK to the project root
|
||||||
|
as `GaCy-Android-<versionName>-debug.apk`. APK files are excluded by `.gitignore`.
|
||||||
|
|
||||||
|
The packaging script uses the checkout's current source and resources and
|
||||||
|
seeds player data from its committed defaults. Runtime saves are stored under
|
||||||
|
the app's `files/gacy-go` directory; the previous ppSR data remains separate.
|
||||||
|
When runtime data is updated, new entries from bundled `hotfix.json` and
|
||||||
|
`versions.json` are added without replacing entries already on the device.
|
||||||
|
If a previous failed lookup left an entirely empty cached entry, the SDK uses
|
||||||
|
available bundled metadata for that version instead of the empty entry.
|
||||||
|
Set the Host in Settings to the address the game client should use to reach
|
||||||
|
the phone (use `127.0.0.1` when both run on the same device). The Go servers
|
||||||
|
use the configured SDK and game ports. Redirecting the game client's dispatch
|
||||||
|
traffic is still a separate setup step.
|
||||||
|
|
||||||
|
Network settings take effect after restarting the server. Remote mode checks
|
||||||
|
the SDK health endpoint periodically and reports an error after three
|
||||||
|
consecutive failed checks.
|
||||||
|
The connection test clears its previous result when the host, ports or mode
|
||||||
|
change, and tests the values currently entered in Settings.
|
||||||
|
|
||||||
|
For an x86_64 Android emulator only, add `--emulator` to the packaging
|
||||||
|
command and `-PgacyEmulator` to the Gradle command. This adds Linux x86_64
|
||||||
|
test executables to the debug APK; ordinary builds contain Android ARM64 only.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -11,7 +56,7 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
|
|||||||
[](https://developer.android.com/ndk)
|
[](https://developer.android.com/ndk)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
[English](#-english) | [ภาษาไทย](#-ภาษาไทย)
|
[ภาษาไทย](README.md) | [English](README_EN.md) | [简体中文](README_ZH.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,24 +67,17 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
|
|||||||
### ✨ ฟีเจอร์หลัก (Key Features)
|
### ✨ ฟีเจอร์หลัก (Key Features)
|
||||||
|
|
||||||
* **🎮 ระบบจัดการเซิร์ฟเวอร์แบบ Dual-Mode (Local & Remote Server Management):**
|
* **🎮 ระบบจัดการเซิร์ฟเวอร์แบบ 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)
|
* **โหมด Remote Host:** เชื่อมต่อและควบคุมเซิร์ฟเวอร์ GaCy-GO บน PC ผ่าน Wi-Fi/LAN พร้อมระบบทดสอบค่าความหน่วง (Ping Latency Test)
|
||||||
* **Live Terminal Console:** หน้าต่างแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบ Real-time พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear)
|
* **Live Terminal Console:** หน้าต่างแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบ Real-time พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear)
|
||||||
* **🗺️ ระบบวาร์ปแผนที่ 8 โลก 86 ฉาก (Escape Penacony Teleport Engine):**
|
* **🗺️ วาร์ปในเกม:** ใช้แผนที่ของเกมผ่าน Game server ของ GaCy Go โดยตรง
|
||||||
* ครอบคลุม **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% ไม่สร้างความเสียหายแก่ข้อมูลตัวละคร
|
|
||||||
* **⚡ คอนโซลคำสั่ง GM บนมือถือ & เชื่อมต่อ GaCy SRTool:**
|
* **⚡ คอนโซลคำสั่ง GM บนมือถือ & เชื่อมต่อ GaCy SRTool:**
|
||||||
* **Emergency Unstuck:** ปุ่มปลดบล็อกตัวละครเร่งด่วนผ่าน Heartbeat API (`/api/client/unstuck`) กู้คืนอาการเกมค้าง
|
* **Emergency Unstuck:** ส่งคำขอกู้คืน UI ผ่าน Heartbeat API (`/api/client/unstuck`) แอปแสดงว่าเข้าคิวเมื่อเซิร์ฟเวอร์ตอบกลับพร้อมรหัสคำขอเท่านั้น; การเข้าคิวไม่ได้ยืนยันว่าเกมประมวลผลสำเร็จ
|
||||||
* **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM (`/give`, `/avatar`, `/faketime`) ตรงไปยังเซิร์ฟเวอร์ พร้อมปุ่มคีย์ลัดคำสั่งยอดฮิต
|
* **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/)
|
* **GaCy SRTool Web Integration:** ปุ่มลัดเปิดหน้าจัดการกระเป๋า ตัวละคร และสร้าง Relic ผ่าน [GaCy SRTool Web](https://gacy-srtool.vercel.app/)
|
||||||
* **🛠️ เครื่องมือปรับแต่งตัวละครและเซิร์ฟเวอร์ (Character & Server Configurator):**
|
* **⚙️ การตั้งค่า:** ตั้งค่าการเชื่อมต่อและภาษาของแอป; การตั้งค่าตัวละครจาก ppSR รุ่นเก่ายังไม่ได้เชื่อมกับ Go runtime
|
||||||
* **🔑 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% แก้ปัญหาหน้าจอดำ
|
|
||||||
* **🎨 หน้าต่าง Cyberpunk Dark สวยงาม ลื่นไหล:**
|
* **🎨 หน้าต่าง Cyberpunk Dark สวยงาม ลื่นไหล:**
|
||||||
* ออกแบบด้วย **Material Design 3** สีสันเข้าชุดกับ G-Launcher Desktop และ GaCy-GO
|
* ออกแบบด้วย **Material Design 3** สีสันเข้าชุดกับ G-Launcher Desktop และ GaCy-GO
|
||||||
* รองรับ 3 ภาษาภายในตัว: **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)**
|
* รองรับ 3 ภาษาภายในตัว: **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)**
|
||||||
@@ -53,11 +91,9 @@ graph TD
|
|||||||
subgraph AppLayer [UI Layer: Jetpack Compose]
|
subgraph AppLayer [UI Layer: Jetpack Compose]
|
||||||
MainUI[MainScreen & Navigation]
|
MainUI[MainScreen & Navigation]
|
||||||
ServerTab[ServerScreen & Live Terminal]
|
ServerTab[ServerScreen & Live Terminal]
|
||||||
TeleportTab[TeleportScreen & 86 Scenes]
|
|
||||||
SRToolTab[SRToolScreen & GM Console]
|
SRToolTab[SRToolScreen & GM Console]
|
||||||
SettingsTab[SettingsScreen & Network Config]
|
SettingsTab[SettingsScreen & Network Config]
|
||||||
MainUI --> ServerTab
|
MainUI --> ServerTab
|
||||||
MainUI --> TeleportTab
|
|
||||||
MainUI --> SRToolTab
|
MainUI --> SRToolTab
|
||||||
MainUI --> SettingsTab
|
MainUI --> SettingsTab
|
||||||
end
|
end
|
||||||
@@ -70,27 +106,22 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph DataRepositories [Data & Repository Layer]
|
subgraph DataRepositories [Data & Repository Layer]
|
||||||
SceneRepo[SceneRepository - scenes.json Cache]
|
|
||||||
PersistRepo[PersistentRepository - persistent.json]
|
|
||||||
ApiRepo[GaCyApiRepository - OkHttp Client]
|
ApiRepo[GaCyApiRepository - OkHttp Client]
|
||||||
PrefsRepo[PreferencesRepository - SharedPreferences]
|
PrefsRepo[PreferencesRepository - SharedPreferences]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph GameEcosystem [GaCy Ecosystem & Storage]
|
subgraph GameEcosystem [GaCy Ecosystem & Storage]
|
||||||
GameServer[GaCy-GO Server / Remote PC]
|
GameServer[GaCy-GO Server / Remote PC]
|
||||||
PersistentData[persistent.json / misc Configs]
|
PersistentData[Go runtime saves and resources]
|
||||||
SRToolWeb[GaCy SRTool Web Application]
|
SRToolWeb[GaCy SRTool Web Application]
|
||||||
StarRailClient[Star Rail Game Client Android]
|
StarRailClient[Star Rail Game Client Android]
|
||||||
end
|
end
|
||||||
|
|
||||||
ServerTab --> PersistRepo
|
|
||||||
ServerTab --> ServerFGS
|
ServerTab --> ServerFGS
|
||||||
TeleportTab --> SceneRepo
|
ServerFGS --> PersistentData
|
||||||
TeleportTab --> PersistRepo
|
|
||||||
SRToolTab --> ApiRepo
|
SRToolTab --> ApiRepo
|
||||||
SettingsTab --> PrefsRepo
|
SettingsTab --> PrefsRepo
|
||||||
|
|
||||||
PersistRepo --> PersistentData
|
|
||||||
ApiRepo --> GameServer
|
ApiRepo --> GameServer
|
||||||
SRToolTab -.-> SRToolWeb
|
SRToolTab -.-> SRToolWeb
|
||||||
StarRailClient -.-> GameServer
|
StarRailClient -.-> GameServer
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -12,20 +12,21 @@ android {
|
|||||||
applicationId = "com.gacy.android"
|
applicationId = "com.gacy.android"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 7
|
||||||
versionName = "v1.0.0"
|
versionName = "v1.0.6"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a")
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
if (providers.gradleProperty("gacyEmulator").isPresent) abiFilters += "x86_64"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
jniLibs.srcDirs("src/main/jniLibs")
|
jniLibs.srcDirs("src/main/jniLibs")
|
||||||
assets.srcDirs("src/main/assets")
|
assets.setSrcDirs(listOf("src/goAssets"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +51,16 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
keepDebugSymbols += "**/libgacy_*.so"
|
||||||
|
excludes += listOf("**/libchaquopy*.so", "**/libpython*.so", "**/libcrypto_*.so", "**/libsqlite3_*.so", "**/libssl_*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(files("libs/chaquopy_java.jar"))
|
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
||||||
@@ -72,3 +78,30 @@ dependencies {
|
|||||||
testImplementation("org.json:json:20240303")
|
testImplementation("org.json:json:20240303")
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
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.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.chaquo.python.Python
|
|
||||||
import com.chaquo.python.android.AndroidPlatform
|
|
||||||
|
|
||||||
class GaCyApplication : Application() {
|
class GaCyApplication : Application() {
|
||||||
|
|
||||||
@@ -18,17 +16,11 @@ class GaCyApplication : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
CrashTracker.init(this)
|
||||||
|
CrashTracker.breadcrumb(this, "GaCyApplication: onCreate")
|
||||||
instance = this
|
instance = this
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
// Initialize embedded Python runtime
|
|
||||||
try {
|
|
||||||
if (!Python.isStarted()) {
|
|
||||||
Python.start(AndroidPlatform(this))
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
t.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
package com.gacy.android
|
package com.gacy.android
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
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.screens.MainScreen
|
||||||
import com.gacy.android.ui.theme.GaCyTheme
|
import com.gacy.android.ui.theme.GaCyTheme
|
||||||
|
|
||||||
@@ -16,12 +37,83 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
requestAppPermissions()
|
requestAppPermissions()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
GaCyTheme {
|
GaCyTheme {
|
||||||
|
var crashReport by remember { mutableStateOf(CrashTracker.getLastCrash(this@MainActivity)) }
|
||||||
|
|
||||||
MainScreen()
|
MainScreen()
|
||||||
|
|
||||||
|
if (crashReport != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
CrashTracker.clearLastCrash(this@MainActivity)
|
||||||
|
crashReport = null
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"⚠️ GaCy Crash Report",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"A fatal error occurred in the previous session:",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
text = crashReport ?: "",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.background(Color(0xFF1E1E1E), RoundedCornerShape(8.dp))
|
||||||
|
.padding(8.dp),
|
||||||
|
color = Color(0xFFFF8A80)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||||
|
val clip = ClipData.newPlainText("GaCy Crash", crashReport)
|
||||||
|
clipboard?.setPrimaryClip(clip)
|
||||||
|
Toast.makeText(this@MainActivity, "Crash report copied!", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Copy Log")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
CrashTracker.clearLastCrash(this@MainActivity)
|
||||||
|
crashReport = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,42 +9,44 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.TimeUnit
|
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)
|
.connectTimeout(3, TimeUnit.SECONDS)
|
||||||
.readTimeout(5, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
.writeTimeout(5, TimeUnit.SECONDS)
|
.writeTimeout(5, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()) {
|
||||||
|
|
||||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
|
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
val url = "${config.dispatchUrl}/status"
|
val url = "${config.dispatchUrl}/api/status"
|
||||||
try {
|
try {
|
||||||
val request = Request.Builder().url(url).get().build()
|
val request = Request.Builder().url(url).get().build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
val duration = System.currentTimeMillis() - start
|
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) {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendUnstuck(config: ServerConfig): GmCommandResult = withContext(Dispatchers.IO) {
|
suspend fun sendUnstuck(config: ServerConfig): GmCommandResult = withContext(Dispatchers.IO) {
|
||||||
val url = "${config.dispatchUrl}/api/client/unstuck"
|
val url = "${config.dispatchUrl}/api/client/unstuck"
|
||||||
@@ -53,19 +55,19 @@ class GaCyApiRepository {
|
|||||||
val request = Request.Builder().url(url).post(body).build()
|
val request = Request.Builder().url(url).post(body).build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
GmCommandResult(true, "Unstuck packet sent to game client successfully.")
|
parseUnstuckResponse(response.body?.string().orEmpty())
|
||||||
} else {
|
} else {
|
||||||
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
|
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
GmCommandResult(false, "Network error: ${e.message}")
|
GmCommandResult(false, "Network error: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
|
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
|
||||||
val cleanCmd = command.trim()
|
val cleanCmd = command.trim()
|
||||||
val url = "${config.dispatchUrl}/api/gm/command"
|
val url = "${config.dispatchUrl}/api/command"
|
||||||
try {
|
try {
|
||||||
val payload = JSONObject().apply {
|
val payload = JSONObject().apply {
|
||||||
put("command", cleanCmd)
|
put("command", cleanCmd)
|
||||||
@@ -75,7 +77,11 @@ class GaCyApiRepository {
|
|||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
val responseBody = response.body?.string() ?: ""
|
val responseBody = response.body?.string() ?: ""
|
||||||
if (response.isSuccessful) {
|
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 {
|
} else {
|
||||||
GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)")
|
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
|
package com.gacy.android.model
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
|
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
|
||||||
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
|
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
|
||||||
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
|
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
|
||||||
@@ -22,7 +24,19 @@ data class ServerConfig(
|
|||||||
val sendHotfix: Boolean = true
|
val sendHotfix: Boolean = true
|
||||||
) {
|
) {
|
||||||
val dispatchUrl: String
|
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(
|
data class GmCommandResult(
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import java.util.Locale
|
|||||||
object LogBuffer {
|
object LogBuffer {
|
||||||
|
|
||||||
private const val MAX_LOG_LINES = 500
|
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 timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
|
private val diagnostics = ArrayDeque<String>()
|
||||||
|
|
||||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||||
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||||
@@ -19,6 +21,10 @@ object LogBuffer {
|
|||||||
fun append(message: String) {
|
fun append(message: String) {
|
||||||
val timestamp = timeFormat.format(Date())
|
val timestamp = timeFormat.format(Date())
|
||||||
val formatted = "[$timestamp] $message"
|
val formatted = "[$timestamp] $message"
|
||||||
|
if (isDiagnostic(message)) {
|
||||||
|
diagnostics.addLast(formatted)
|
||||||
|
if (diagnostics.size > MAX_DIAGNOSTIC_LINES) diagnostics.removeFirst()
|
||||||
|
}
|
||||||
val current = _logs.value.toMutableList()
|
val current = _logs.value.toMutableList()
|
||||||
current.add(formatted)
|
current.add(formatted)
|
||||||
if (current.size > MAX_LOG_LINES) {
|
if (current.size > MAX_LOG_LINES) {
|
||||||
@@ -30,9 +36,18 @@ object LogBuffer {
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_logs.value = emptyList()
|
_logs.value = emptyList()
|
||||||
|
diagnostics.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun getAllText(): String {
|
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.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
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.GaCyApplication
|
||||||
import com.gacy.android.MainActivity
|
import com.gacy.android.MainActivity
|
||||||
import com.gacy.android.R
|
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.PreferencesRepository
|
||||||
import com.gacy.android.data.UpdateRepository
|
import com.gacy.android.model.ServerConfig
|
||||||
import com.gacy.android.model.ServerExecutionMode
|
import com.gacy.android.model.ServerExecutionMode
|
||||||
import com.gacy.android.model.ServerStatus
|
import com.gacy.android.model.ServerStatus
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.InetSocketAddress
|
import java.util.concurrent.TimeUnit
|
||||||
import java.net.Socket
|
|
||||||
|
|
||||||
class ServerLogReceiver {
|
|
||||||
fun onServerLog(msg: String) {
|
|
||||||
LogBuffer.append(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServerService : Service() {
|
class ServerService : Service() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private var job: Job? = null
|
||||||
private var serverThread: Thread? = null
|
private val processes = mutableListOf<Process>()
|
||||||
private var probeJob: Job? = null
|
private val lock = Any()
|
||||||
private var executionJob: Job? = null
|
@Volatile private var generation = 0
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val action = intent?.action ?: ACTION_START
|
when (intent?.action ?: ACTION_START) {
|
||||||
|
|
||||||
when (action) {
|
|
||||||
ACTION_START -> startServer()
|
ACTION_START -> startServer()
|
||||||
ACTION_STOP -> stopServer()
|
ACTION_STOP -> stopServer()
|
||||||
ACTION_RESTART -> restartServer()
|
ACTION_RESTART -> { stopServer(false); startServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startServer() {
|
private fun startServer() {
|
||||||
if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) {
|
if (_serverStatus.value == ServerStatus.STARTING || _serverStatus.value == ServerStatus.RUNNING) return
|
||||||
return
|
val run = ++generation
|
||||||
}
|
val config = PreferencesRepository(this).getServerConfig()
|
||||||
|
|
||||||
try {
|
|
||||||
val prefs = PreferencesRepository(this)
|
|
||||||
val config = prefs.getServerConfig()
|
|
||||||
|
|
||||||
_serverStatus.value = ServerStatus.STARTING
|
_serverStatus.value = ServerStatus.STARTING
|
||||||
startForegroundCompat("GaCy Server is starting...")
|
startForegroundCompat("Starting GaCy Go server")
|
||||||
LogBuffer.append("--- GaCy Server Starting ---")
|
job = scope.launch {
|
||||||
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
|
try {
|
||||||
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
|
|
||||||
|
|
||||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||||
startLocalServerExecution(config.dispatchPort)
|
val runtime = GoRuntimeRepository(applicationContext)
|
||||||
} else {
|
LogBuffer.append("[Setup] Preparing GaCy Go runtime")
|
||||||
startRemoteMonitor(config.remoteHost, config.dispatchPort)
|
runtime.prepare()
|
||||||
|
ensureActive()
|
||||||
|
launchProcess("gameserver", runtime.directory, config, run)
|
||||||
|
launchProcess("sdkserver", runtime.directory, config, run)
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
val api = GaCyApiRepository()
|
||||||
LogBuffer.append("[Start Error] ${t.localizedMessage ?: t.message}")
|
var ready = false
|
||||||
t.printStackTrace()
|
repeat(40) {
|
||||||
_serverStatus.value = ServerStatus.ERROR
|
if (!ready) {
|
||||||
stopForegroundCompat()
|
ensureActive()
|
||||||
stopSelf()
|
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) checkProcesses()
|
||||||
|
ready = api.checkServerHealth(config).first
|
||||||
|
if (!ready) delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!ready) error("SDK server did not respond")
|
||||||
private fun startLocalServerExecution(dispatchPort: Int) {
|
if (run != generation) return@launch
|
||||||
executionJob?.cancel()
|
_serverStatus.value = ServerStatus.RUNNING
|
||||||
executionJob = serviceScope.launch {
|
updateNotification("GaCy Go server is running")
|
||||||
try {
|
LogBuffer.append("[System] SDK server is ready at ${config.dispatchUrl}")
|
||||||
val updateRepo = UpdateRepository(this@ServerService)
|
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||||
if (!updateRepo.isServerInstalled()) {
|
while (isActive && run == generation) {
|
||||||
LogBuffer.append("[Setup] Installing GaCy embedded server binaries from assets...")
|
|
||||||
val success = updateRepo.ensureServerInstalled()
|
|
||||||
if (!success) {
|
|
||||||
LogBuffer.append("[Error] Failed to install server binaries from assets.")
|
|
||||||
_serverStatus.value = ServerStatus.ERROR
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
LogBuffer.append("[Setup] Server binaries installed successfully.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val ppsrFile = File(filesDir, "server/build/build/ppsr.pyc")
|
|
||||||
if (!ppsrFile.exists()) {
|
|
||||||
LogBuffer.append("[Error] Server bytecode missing at: ${ppsrFile.absolutePath}")
|
|
||||||
_serverStatus.value = ServerStatus.ERROR
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val persistentRepo = PersistentRepository(this@ServerService)
|
|
||||||
persistentRepo.ensureValidPersistentJsonSync()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
LogBuffer.append("[Warning] Persistent validation: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
LogBuffer.append("[System] Launching embedded GaCy Python server...")
|
|
||||||
|
|
||||||
// Background TCP port readiness probe
|
|
||||||
probeJob?.cancel()
|
|
||||||
probeJob = serviceScope.launch {
|
|
||||||
var attempts = 0
|
|
||||||
while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 35) {
|
|
||||||
delay(1000)
|
delay(1000)
|
||||||
attempts++
|
checkProcesses()
|
||||||
if (isPortOpen("127.0.0.1", dispatchPort)) {
|
}
|
||||||
_serverStatus.value = ServerStatus.RUNNING
|
} else {
|
||||||
updateNotification("GaCy Server is RUNNING on :$dispatchPort")
|
var failures = 0
|
||||||
LogBuffer.append("[System] GaCy Dispatch server is ready on 127.0.0.1:$dispatchPort")
|
while (isActive && run == generation) {
|
||||||
break
|
delay(5000)
|
||||||
|
val healthy = api.checkServerHealth(config).first
|
||||||
|
ensureActive()
|
||||||
|
failures = if (healthy) 0 else failures + 1
|
||||||
|
if (failures >= 3) error("Remote SDK server stopped responding")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (_: CancellationException) {
|
||||||
|
} catch (e: Exception) {
|
||||||
serverThread = Thread({
|
if (run == generation) {
|
||||||
try {
|
LogBuffer.append("[Server error] ${e.message}")
|
||||||
if (!Python.isStarted()) {
|
terminateProcesses()
|
||||||
Python.start(AndroidPlatform(applicationContext))
|
|
||||||
}
|
|
||||||
val py = Python.getInstance()
|
|
||||||
val runPpsr = py.getModule("run_ppsr")
|
|
||||||
val logReceiver = ServerLogReceiver()
|
|
||||||
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
LogBuffer.append("[Python Error] ${t.localizedMessage ?: t.message}")
|
|
||||||
t.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
probeJob?.cancel()
|
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
|
||||||
LogBuffer.append("[System] Server stopped.")
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}, "GaCy-PythonServer").apply {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
LogBuffer.append("[Execution Error] ${t.localizedMessage ?: t.message}")
|
|
||||||
t.printStackTrace()
|
|
||||||
_serverStatus.value = ServerStatus.ERROR
|
_serverStatus.value = ServerStatus.ERROR
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) {
|
|
||||||
executionJob?.cancel()
|
|
||||||
executionJob = serviceScope.launch {
|
|
||||||
LogBuffer.append("[Remote] Connecting to remote GaCy host at $remoteHost:$dispatchPort...")
|
|
||||||
delay(600)
|
|
||||||
_serverStatus.value = ServerStatus.RUNNING
|
|
||||||
updateNotification("Monitoring Remote GaCy Host at $remoteHost")
|
|
||||||
LogBuffer.append("[Remote] Connected to remote GaCy server monitor.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopServer() {
|
|
||||||
LogBuffer.append("--- GaCy Server Stopping ---")
|
|
||||||
_serverStatus.value = ServerStatus.STOPPING
|
|
||||||
updateNotification("Stopping GaCy Server...")
|
|
||||||
|
|
||||||
probeJob?.cancel()
|
|
||||||
probeJob = null
|
|
||||||
|
|
||||||
executionJob?.cancel()
|
|
||||||
executionJob = null
|
|
||||||
|
|
||||||
serviceScope.launch {
|
|
||||||
try {
|
|
||||||
if (Python.isStarted()) {
|
|
||||||
val py = Python.getInstance()
|
|
||||||
val runPpsr = py.getModule("run_ppsr")
|
|
||||||
runPpsr.callAttr("kill_server_process")
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
LogBuffer.append("[Stop Error] ${e.localizedMessage ?: e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1200)
|
|
||||||
serverThread?.interrupt()
|
|
||||||
serverThread = null
|
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restartServer() {
|
|
||||||
LogBuffer.append("Restarting GaCy Server...")
|
|
||||||
stopServer()
|
|
||||||
serviceScope.launch {
|
|
||||||
delay(1500)
|
|
||||||
startServer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isPortOpen(host: String, port: Int): Boolean {
|
|
||||||
return try {
|
|
||||||
Socket().use { socket ->
|
|
||||||
socket.connect(InetSocketAddress(host, port), 400)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
GaCyApplication.CHANNEL_ID,
|
|
||||||
GaCyApplication.CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Background service for GaCy Game Server & Proxy"
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager?.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotification(contentText: String): Notification {
|
|
||||||
createNotificationChannel()
|
|
||||||
|
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
}
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
launchIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
val stopIntent = Intent(this, ServerService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
val stopPendingIntent = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
1,
|
|
||||||
stopIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
|
|
||||||
.setContentTitle("GaCy Android")
|
|
||||||
.setContentText(contentText)
|
|
||||||
.setSmallIcon(R.drawable.app_icon)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startForegroundCompat(text: String) {
|
|
||||||
try {
|
|
||||||
createNotificationChannel()
|
|
||||||
val notification = createNotification(text)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// API 26..33
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
try {
|
|
||||||
val notification = createNotification(text)
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
} catch (e2: Throwable) {
|
|
||||||
LogBuffer.append("[Foreground Warning] ${e2.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNotification(contentText: String) {
|
|
||||||
try {
|
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
|
||||||
manager?.notify(NOTIFICATION_ID, createNotification(contentText))
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopForegroundCompat() {
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
} else {
|
stopSelf()
|
||||||
@Suppress("DEPRECATION")
|
}
|
||||||
stopForeground(true)
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
private fun checkProcesses() {
|
||||||
probeJob?.cancel()
|
val failed = synchronized(lock) { processes.firstOrNull { !it.isAlive } }
|
||||||
serverThread?.interrupt()
|
if (failed != null) error("A Go server process exited with code ${failed.exitValue()}; see the log")
|
||||||
executionJob?.cancel()
|
}
|
||||||
super.onDestroy()
|
|
||||||
|
private fun launchProcess(name: String, directory: File, config: ServerConfig, run: Int) {
|
||||||
|
val executable = File(applicationInfo.nativeLibraryDir, "libgacy_${name}.so")
|
||||||
|
check(executable.isFile) { "Missing Go runtime: ${executable.name}" }
|
||||||
|
val builder = ProcessBuilder(executable.absolutePath).directory(directory).redirectErrorStream(true)
|
||||||
|
builder.environment().apply {
|
||||||
|
put("GAC_SDK_PORT", config.dispatchPort.toString())
|
||||||
|
put("GAC_GAME_PORT", config.gameserverPort.toString())
|
||||||
|
put("GAC_PUBLIC_GAME_HOST", config.remoteHost)
|
||||||
|
put("GAC_PUBLIC_SDK_URL", "http://${config.remoteHost}:${config.dispatchPort}")
|
||||||
|
}
|
||||||
|
val process = builder.start()
|
||||||
|
synchronized(lock) {
|
||||||
|
if (run != generation) { process.destroy(); throw CancellationException() }
|
||||||
|
processes.add(process)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
process.inputStream.bufferedReader().useLines { lines ->
|
||||||
|
lines.forEach { LogBuffer.append("[$name] $it") }
|
||||||
|
}
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
if (process.isAlive && run == generation) LogBuffer.append("[$name] Log stream error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun terminateProcesses() {
|
||||||
|
val old = synchronized(lock) { processes.toList().also { processes.clear() } }
|
||||||
|
old.forEach { it.destroy() }
|
||||||
|
old.forEach { if (!it.waitFor(2, TimeUnit.SECONDS)) it.destroyForcibly() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopServer(stopService: Boolean = true) {
|
||||||
|
++generation
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
_serverStatus.value = ServerStatus.STOPPING
|
||||||
|
terminateProcesses()
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
_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 {
|
companion object {
|
||||||
@@ -346,51 +179,10 @@ class ServerService : Service() {
|
|||||||
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
|
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
|
||||||
const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART"
|
const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART"
|
||||||
private const val NOTIFICATION_ID = 20261
|
private const val NOTIFICATION_ID = 20261
|
||||||
|
|
||||||
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
|
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
|
||||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||||
|
fun start(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(ACTION_START)) }
|
||||||
fun start(context: Context) {
|
fun stop(context: Context) { context.startService(Intent(context, ServerService::class.java).setAction(ACTION_STOP)) }
|
||||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
|
fun restart(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(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 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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.gacy.android.data.PersistentRepository
|
|
||||||
import com.gacy.android.data.PreferencesRepository
|
import com.gacy.android.data.PreferencesRepository
|
||||||
import com.gacy.android.model.AppLanguage
|
import com.gacy.android.model.AppLanguage
|
||||||
import com.gacy.android.service.ServerService
|
import com.gacy.android.service.ServerService
|
||||||
@@ -24,7 +23,6 @@ enum class NavTab(
|
|||||||
val icon: ImageVector
|
val icon: ImageVector
|
||||||
) {
|
) {
|
||||||
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
|
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
|
||||||
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
|
|
||||||
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
|
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
|
||||||
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
|
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ enum class NavTab(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun MainScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val persistentRepo = remember { PersistentRepository(context) }
|
|
||||||
val prefsRepo = remember { PreferencesRepository(context) }
|
val prefsRepo = remember { PreferencesRepository(context) }
|
||||||
|
|
||||||
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
|
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
|
||||||
@@ -89,14 +86,8 @@ fun MainScreen() {
|
|||||||
NavTab.SERVER -> ServerScreen(
|
NavTab.SERVER -> ServerScreen(
|
||||||
lang = currentLanguage,
|
lang = currentLanguage,
|
||||||
serverStatus = serverStatus,
|
serverStatus = serverStatus,
|
||||||
persistentRepo = persistentRepo,
|
|
||||||
prefsRepo = prefsRepo
|
prefsRepo = prefsRepo
|
||||||
)
|
)
|
||||||
NavTab.TELEPORT -> TeleportScreen(
|
|
||||||
lang = currentLanguage,
|
|
||||||
serverStatus = serverStatus,
|
|
||||||
persistentRepo = persistentRepo
|
|
||||||
)
|
|
||||||
NavTab.SRTOOL -> SRToolScreen(
|
NavTab.SRTOOL -> SRToolScreen(
|
||||||
lang = currentLanguage,
|
lang = currentLanguage,
|
||||||
prefsRepo = prefsRepo
|
prefsRepo = prefsRepo
|
||||||
|
|||||||
@@ -39,15 +39,14 @@ fun SRToolScreen(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
val apiRepo = remember { GaCyApiRepository() }
|
val apiRepo = remember { GaCyApiRepository() }
|
||||||
val serverConfig = remember { prefsRepo.getServerConfig() }
|
|
||||||
|
|
||||||
var commandInput by remember { mutableStateOf("") }
|
var commandInput by remember { mutableStateOf("") }
|
||||||
var commandOutput by remember { mutableStateOf("") }
|
var commandOutput by remember { mutableStateOf("") }
|
||||||
|
var commandSuccess by remember { mutableStateOf(true) }
|
||||||
var isExecuting by remember { mutableStateOf(false) }
|
var isExecuting by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val presetCommands = listOf(
|
val presetCommands = listOf(
|
||||||
"/unstuck",
|
"/unstuck",
|
||||||
"/give 200001 10000",
|
|
||||||
"/avatar 1204 80",
|
"/avatar 1204 80",
|
||||||
"/avatar 1213 80",
|
"/avatar 1213 80",
|
||||||
"/avatar 1308 80",
|
"/avatar 1308 80",
|
||||||
@@ -146,9 +145,10 @@ fun SRToolScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val res = apiRepo.sendUnstuck(serverConfig)
|
val res = apiRepo.sendUnstuck(prefsRepo.getServerConfig())
|
||||||
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
|
||||||
commandOutput = "[UNSTUCK] ${res.message}"
|
commandOutput = "[UNSTUCK] ${res.message}"
|
||||||
|
commandSuccess = res.success
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
|
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
|
||||||
@@ -185,7 +185,7 @@ fun SRToolScreen(
|
|||||||
value = commandInput,
|
value = commandInput,
|
||||||
onValueChange = { commandInput = it },
|
onValueChange = { commandInput = it },
|
||||||
modifier = Modifier.weight(1f),
|
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,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
@@ -201,8 +201,9 @@ fun SRToolScreen(
|
|||||||
if (commandInput.isNotBlank()) {
|
if (commandInput.isNotBlank()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isExecuting = true
|
isExecuting = true
|
||||||
val result = apiRepo.executeGmCommand(serverConfig, commandInput)
|
val result = apiRepo.executeGmCommand(prefsRepo.getServerConfig(), commandInput)
|
||||||
commandOutput = result.message
|
commandOutput = result.message
|
||||||
|
commandSuccess = result.success
|
||||||
isExecuting = false
|
isExecuting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +230,7 @@ fun SRToolScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = commandOutput,
|
text = commandOutput,
|
||||||
color = TerminalGreen,
|
color = if (commandSuccess) TerminalGreen else GaCyRed,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
|
|||||||
@@ -1,388 +1,41 @@
|
|||||||
package com.gacy.android.ui.screens
|
package com.gacy.android.ui.screens
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
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.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.LogBuffer
|
||||||
import com.gacy.android.service.ServerService
|
import com.gacy.android.service.ServerService
|
||||||
import com.gacy.android.ui.components.*
|
import com.gacy.android.ui.components.TerminalConsole
|
||||||
import com.gacy.android.ui.theme.*
|
import com.gacy.android.ui.components.tr
|
||||||
import kotlinx.coroutines.launch
|
import com.gacy.android.ui.theme.BackgroundDark
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerScreen(
|
fun ServerScreen(lang: AppLanguage, serverStatus: ServerStatus, prefsRepo: PreferencesRepository) {
|
||||||
lang: AppLanguage,
|
|
||||||
serverStatus: ServerStatus,
|
|
||||||
persistentRepo: PersistentRepository,
|
|
||||||
prefsRepo: PreferencesRepository
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
val logs by LogBuffer.logs.collectAsState()
|
val logs by LogBuffer.logs.collectAsState()
|
||||||
var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) }
|
val config = prefsRepo.getServerConfig()
|
||||||
|
val active = serverStatus == ServerStatus.RUNNING || serverStatus == ServerStatus.STARTING
|
||||||
var sendHotfix by remember { mutableStateOf(true) }
|
Column(Modifier.fillMaxSize().background(BackgroundDark).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
var serverKey by remember { mutableStateOf("") }
|
Text("GaCy Go — ${serverStatus.name}", style = MaterialTheme.typography.titleLarge)
|
||||||
var tbConfig by remember { mutableStateOf(TrailblazerConfig()) }
|
Text("SDK: ${config.dispatchPort} | Game: ${config.gameserverPort}")
|
||||||
var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
|
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)) {
|
||||||
// Dialog flags
|
Button(onClick = { if (active) ServerService.stop(context) else ServerService.start(context) }, enabled = serverStatus != ServerStatus.STOPPING) {
|
||||||
var showKeyDialog by remember { mutableStateOf(false) }
|
Text(if (active) tr(lang, "Stop", "หยุด", "停止") else tr(lang, "Start", "เริ่ม", "启动"))
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { ServerService.restart(context) }, enabled = serverStatus != ServerStatus.STARTING && serverStatus != ServerStatus.STOPPING) {
|
||||||
Column(
|
Text(tr(lang, "Restart", "เริ่มใหม่", "重启"))
|
||||||
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", "รีสตาร์ต", "重启"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminal Console
|
|
||||||
TerminalConsole(logs = logs, lang = lang)
|
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(
|
RadioButton(
|
||||||
selected = mode == m,
|
selected = mode == m,
|
||||||
onClick = { mode = m },
|
onClick = { mode = m; pingStatus = null },
|
||||||
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
|
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -112,7 +112,7 @@ fun SettingsScreen(
|
|||||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it; pingStatus = null },
|
||||||
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
|
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -125,7 +125,7 @@ fun SettingsScreen(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = dispatchPort,
|
value = dispatchPort,
|
||||||
onValueChange = { dispatchPort = it },
|
onValueChange = { dispatchPort = it; pingStatus = null },
|
||||||
label = { Text("Dispatch Port") },
|
label = { Text("Dispatch Port") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -137,7 +137,7 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = gameserverPort,
|
value = gameserverPort,
|
||||||
onValueChange = { gameserverPort = it },
|
onValueChange = { gameserverPort = it; pingStatus = null },
|
||||||
label = { Text("Game Port") },
|
label = { Text("Game Port") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -155,17 +155,15 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val dPort = dispatchPort.toIntOrNull() ?: 21000
|
val newCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
|
||||||
val gPort = gameserverPort.toIntOrNull() ?: 23301
|
if (newCfg == null) {
|
||||||
val newCfg = ServerConfig(
|
Toast.makeText(context, tr(lang, "Enter a valid host and ports from 1 to 65535", "กรอก Host และพอร์ตตั้งแต่ 1 ถึง 65535 ให้ถูกต้อง", "请输入有效主机及 1 至 65535 的端口"), Toast.LENGTH_LONG).show()
|
||||||
executionMode = mode,
|
} else {
|
||||||
remoteHost = host,
|
|
||||||
dispatchPort = dPort,
|
|
||||||
gameserverPort = gPort
|
|
||||||
)
|
|
||||||
config = newCfg
|
config = newCfg
|
||||||
|
host = newCfg.remoteHost
|
||||||
prefsRepo.saveServerConfig(newCfg)
|
prefsRepo.saveServerConfig(newCfg)
|
||||||
Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, tr(lang, "Saved. Restart the server to apply changes.", "บันทึกแล้ว เริ่มเซิร์ฟเวอร์ใหม่เพื่อใช้ค่า", "已保存,重启服务器后生效。"), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
|
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
@@ -174,18 +172,23 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
enabled = !isPinging,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
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
|
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)
|
val (ok, duration) = apiRepo.checkServerHealth(testCfg)
|
||||||
|
if (config.withNetworkInput(mode, host, dispatchPort, gameserverPort) == testCfg) {
|
||||||
pingStatus = if (ok) {
|
pingStatus = if (ok) {
|
||||||
"Connected! Latency: ${duration}ms"
|
"Connected! Latency: ${duration}ms"
|
||||||
} else {
|
} else {
|
||||||
"Host unreachable or offline"
|
"Host unreachable or offline"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
isPinging = false
|
isPinging = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -228,7 +231,7 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
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,
|
color = TextSecondary,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
lineHeight = 16.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
|
package com.gacy.android
|
||||||
|
|
||||||
import com.gacy.android.model.SceneConfig
|
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 com.gacy.android.model.TrailblazerConfig
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ModelAndConfigTest {
|
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
|
@Test
|
||||||
fun testSceneConfigSerialization() {
|
fun testSceneConfigSerialization() {
|
||||||
val config = SceneConfig(
|
val config = SceneConfig(
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.gacy.android
|
||||||
|
|
||||||
|
import com.gacy.android.data.parseUnstuckResponse
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UnstuckResponseTest {
|
||||||
|
@Test fun confirmsOnlyQueuedRequestsWithAnId() {
|
||||||
|
assertTrue(parseUnstuckResponse("""{"state":"queued","request_id":"abc"}""").success)
|
||||||
|
assertFalse(parseUnstuckResponse("""{"retcode":0,"message":"OK"}""").success)
|
||||||
|
assertFalse(parseUnstuckResponse("""{"state":"queued"}""").success)
|
||||||
|
assertFalse(parseUnstuckResponse("""{"state":"queued","request_id":null}""").success)
|
||||||
|
assertFalse(parseUnstuckResponse("""{"state":"failed","request_id":"abc"}""").success)
|
||||||
|
assertFalse(parseUnstuckResponse("not json").success)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Build GaCy Go for Android and package its runtime data.
|
||||||
|
|
||||||
|
Usage: python tools/package_go_runtime.py PATH_TO_GAC_GO
|
||||||
|
The source checkout is read only; its current source/resources are packaged.
|
||||||
|
Mutable saves are seeded from the checkout's committed defaults, never from
|
||||||
|
the operator's live working files.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
source = Path(sys.argv[1]).resolve()
|
||||||
|
if not (source / "go.mod").is_file():
|
||||||
|
raise SystemExit("Expected a GaC-hkrpg-go checkout")
|
||||||
|
|
||||||
|
native = root / "app/src/main/jniLibs/arm64-v8a"
|
||||||
|
native.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(GOOS="android", GOARCH="arm64", CGO_ENABLED="0")
|
||||||
|
for name in ("sdkserver", "gameserver"):
|
||||||
|
subprocess.run(["go", "build", "-o", str(native / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||||
|
cwd=source, env=env, check=True)
|
||||||
|
|
||||||
|
# Optional emulator-only binaries use Linux syscalls on the x86_64 emulator.
|
||||||
|
# They are excluded from ordinary ARM64 builds.
|
||||||
|
if "--emulator" in sys.argv[2:]:
|
||||||
|
emulator = root / "app/src/debug/jniLibs/x86_64"
|
||||||
|
emulator.mkdir(parents=True, exist_ok=True)
|
||||||
|
env.update(GOOS="linux", GOARCH="amd64", CGO_ENABLED="0")
|
||||||
|
for name in ("sdkserver", "gameserver"):
|
||||||
|
subprocess.run(["go", "build", "-o", str(emulator / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||||
|
cwd=source, env=env, check=True)
|
||||||
|
|
||||||
|
archive = root / "app/src/goAssets/gacy-go-data.zip"
|
||||||
|
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
resources = ("server.json", "hotfix.json", "versions.json", "res.json",
|
||||||
|
"teleports.json", "challenge_data.json", "mission_unlocks.json")
|
||||||
|
scripts = ("damage_analyst_live.lua", "fps_settings_120.lua", "freecam.lua",
|
||||||
|
"hide_ui.lua", "navigation_profiles.lua", "ping_rainbow.lua")
|
||||||
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
||||||
|
for name in resources:
|
||||||
|
z.write(source / name, name)
|
||||||
|
for path in (source / "resources").rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
z.write(path, path.relative_to(source).as_posix())
|
||||||
|
for name in scripts:
|
||||||
|
z.write(source / "scripts" / name, "scripts/" + name)
|
||||||
|
for name in ("persistent", "freesr-data.json"):
|
||||||
|
data = subprocess.check_output(["git", "show", f"HEAD:{name}"], cwd=source)
|
||||||
|
z.writestr(name, data)
|
||||||
|
print(f"Packaged Go runtime: {archive}")
|
||||||
Reference in new issue
Block a user