Add Android control app and embedded server

Inspired by HNKServer/honoka-chan-apk-server, but implemented as a JNI-based embedded runtime instead of the original approach.

- Add an Android Compose controller app that starts and stops honoka-chan through JNI, shows runtime and health status, manages data directory mounting, and supports backup import/export for data.db
- Add an embeddable Go server entrypoint plus JNI-exported status, health, and reload hooks so desktop and Android builds share the same startup and shutdown path
- Add Android build scripts, runtime packaging, and project documentation, including generated default config.json content for honoka_runtime.zip
- Add runtime bundle hash tracking so updated honoka_runtime.zip assets are automatically redeployed while preserving config.json and user data files
- Add in-app service settings for unlock_all_special_rotation with immediate config persistence and automatic reload when the service is running
- Split SQLite driver selection by platform, using go-sqlite3 on Android and modernc.org/sqlite elsewhere to avoid Android x86_64 seccomp crashes
- Update startup, database initialization, and system health/reload handlers to support the embedded runtime and Android control flow

Signed-off-by: Sean Du <do4suki@gmail.com>
This commit is contained in:
2026-06-12 19:17:21 +08:00
parent 5c2d68f979
commit 91bbc7f607
64 changed files with 3230 additions and 286 deletions
+139
View File
@@ -0,0 +1,139 @@
package system
import (
"encoding/json"
"errors"
"honoka-chan/config"
"honoka-chan/internal/router"
systemschema "honoka-chan/internal/schema/system"
"honoka-chan/pkg/db"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
)
var (
startedAt = time.Now()
lastReloadAt atomic.Int64
)
const dateTimeFormat = "2006-01-02 15:04:05"
func MarkStarted(t time.Time) {
startedAt = t
}
func Health() (systemschema.HealthResp, int) {
appName := config.Conf.AppName
if appName == "" {
appName = "honoka-chan"
}
mainDBStatus := "not initialized"
if db.MainEng != nil {
mainDBStatus = "ok"
if err := db.MainEng.Ping(); err != nil {
mainDBStatus = err.Error()
}
}
userDBStatus := "not initialized"
if db.UserEng != nil {
userDBStatus = "ok"
if err := db.UserEng.Ping(); err != nil {
userDBStatus = err.Error()
}
}
status := "ok"
statusCode := http.StatusOK
if db.MainEng == nil || db.UserEng == nil {
status = "stopped"
}
resp := systemschema.HealthResp{
Status: status,
AppName: appName,
Version: config.PackageVersion,
StartedAt: startedAt.Format(dateTimeFormat),
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
ListenPort: config.Conf.Settings.ListenPort,
CdnServer: config.Conf.Settings.CdnServer,
ReloadTokenConfigured: strings.TrimSpace(config.Conf.Settings.ReloadToken) != "",
MainDB: mainDBStatus,
UserDB: userDBStatus,
}
if ts := lastReloadAt.Load(); ts > 0 {
resp.LastReloadAt = time.Unix(ts, 0).Format(dateTimeFormat)
}
if status == "ok" && (mainDBStatus != "ok" || userDBStatus != "ok") {
resp.Status = "degraded"
statusCode = http.StatusServiceUnavailable
}
return resp, statusCode
}
func HealthJSON() string {
resp, _ := Health()
data, err := json.Marshal(resp)
if err != nil {
return `{"status":"error","app_name":"honoka-chan"}`
}
return string(data)
}
func Reload(token string, requireToken bool) (systemschema.ReloadResp, int, error) {
expectedToken := strings.TrimSpace(config.Conf.Settings.ReloadToken)
if requireToken && expectedToken == "" {
return systemschema.ReloadResp{
Status: "error",
Message: "reload_token is not configured",
}, http.StatusServiceUnavailable, errors.New("reload_token is not configured")
}
if requireToken && strings.TrimSpace(token) != expectedToken {
return systemschema.ReloadResp{
Status: "error",
Message: "invalid reload token",
}, http.StatusUnauthorized, errors.New("invalid reload token")
}
config.ReloadConfig()
now := time.Now()
lastReloadAt.Store(now.Unix())
return systemschema.ReloadResp{
Status: "ok",
Message: "configuration reloaded",
ReloadedAt: now.Format(dateTimeFormat),
}, http.StatusOK, nil
}
func health(ctx *gin.Context) {
resp, statusCode := Health()
ctx.JSON(statusCode, resp)
}
func reload(ctx *gin.Context) {
token := strings.TrimSpace(ctx.GetHeader("X-Reload-Token"))
if token == "" {
token = strings.TrimSpace(ctx.Query("token"))
}
if token == "" {
token = strings.TrimSpace(ctx.PostForm("token"))
}
resp, statusCode, _ := Reload(token, true)
ctx.JSON(statusCode, resp)
}
func init() {
router.AddHandler("/", "GET", "/system/health", health)
router.AddHandler("/", "POST", "/system/reload", reload)
}