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
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HonokaControl"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.HonokaServerService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
+18
View File
@@ -0,0 +1,18 @@
cmake_minimum_required(VERSION 3.22.1)
project(honoka_android)
add_library(
honoka_android
SHARED
honoka_android.cpp
)
find_library(log-lib log)
find_library(dl-lib dl)
target_link_libraries(
honoka_android
${log-lib}
${dl-lib}
)
+133
View File
@@ -0,0 +1,133 @@
#include <jni.h>
#include <dlfcn.h>
#include <mutex>
#include <string>
namespace {
using server_start_fn = char* (*)(const char*);
using server_stop_fn = char* (*)();
using server_status_fn = char* (*)();
using server_health_fn = char* (*)();
using server_reload_fn = char* (*)();
using server_free_string_fn = void (*)(char*);
std::once_flag g_init_once;
void* g_handle = nullptr;
server_start_fn g_start = nullptr;
server_stop_fn g_stop = nullptr;
server_status_fn g_status = nullptr;
server_health_fn g_health = nullptr;
server_reload_fn g_reload = nullptr;
server_free_string_fn g_free_string = nullptr;
std::string g_error;
void InitServerSymbols() {
g_handle = dlopen("libhonokachan.so", RTLD_NOW | RTLD_GLOBAL);
if (g_handle == nullptr) {
g_error = dlerror();
return;
}
g_start = reinterpret_cast<server_start_fn>(dlsym(g_handle, "ServerStart"));
g_stop = reinterpret_cast<server_stop_fn>(dlsym(g_handle, "ServerStop"));
g_status = reinterpret_cast<server_status_fn>(dlsym(g_handle, "ServerStatusJSON"));
g_health = reinterpret_cast<server_health_fn>(dlsym(g_handle, "ServerHealthJSON"));
g_reload = reinterpret_cast<server_reload_fn>(dlsym(g_handle, "ServerReload"));
g_free_string = reinterpret_cast<server_free_string_fn>(dlsym(g_handle, "ServerFreeString"));
if (g_start == nullptr || g_stop == nullptr || g_status == nullptr || g_health == nullptr ||
g_reload == nullptr || g_free_string == nullptr) {
g_error = "missing exported symbols from libhonokachan.so";
}
}
const char* EnsureServerReady() {
std::call_once(g_init_once, InitServerSymbols);
if (!g_error.empty()) {
return g_error.c_str();
}
return nullptr;
}
jstring CallStringResult(JNIEnv* env, char* result) {
if (result == nullptr) {
return nullptr;
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
} // namespace
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStart(JNIEnv* env, jobject, jstring work_dir) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
const char* work_dir_chars = env->GetStringUTFChars(work_dir, nullptr);
char* result = g_start(work_dir_chars);
env->ReleaseStringUTFChars(work_dir, work_dir_chars);
return CallStringResult(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStop(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_stop());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStatusJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\"running\":false,\"last_error\":\"") + error + "\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_status();
if (result == nullptr) {
return env->NewStringUTF("{\"running\":false,\"last_error\":\"status unavailable\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeHealthJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"") + error + "\\\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_health();
if (result == nullptr) {
return env->NewStringUTF("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"health unavailable\\\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeReload(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_reload());
}
@@ -0,0 +1,340 @@
package me.killkiss.honokactrl
import android.Manifest
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import me.killkiss.honokactrl.ui.MainScreen
import me.killkiss.honokactrl.ui.MainViewModel
import me.killkiss.honokactrl.theme.HonokaControlTheme
import me.killkiss.honokactrl.storage.RuntimePreparer
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
private var pendingDirectoryPicker = false
private var pendingStartServer = false
private var batteryOptimizationDialogShown = false
private val runtimePreparer by lazy { RuntimePreparer(this) }
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val openDocumentTreeLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
handleSelectedDirectory(uri)
}
private val exportBackupLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
if (uri != null) {
exportBackupToUri(uri)
}
}
private val importBackupLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
confirmImportBackup(uri)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
HonokaControlTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MainScreen(
viewModel = viewModel,
onToggleServer = { handleToggleServer() },
onOpenLocalUrl = { openLocalUrl() },
onPickDataDirectory = { openDataDirectoryPicker() },
onExportBackup = { openExportBackup() },
onImportBackup = { openImportBackup() },
)
}
}
}
requestNotificationPermission()
}
override fun onResume() {
super.onResume()
viewModel.refreshAllFilesAccess(hasAllFilesAccess())
viewModel.refreshStatus()
maybeShowBatteryOptimizationDialog()
if (pendingStartServer && hasAllFilesAccess()) {
pendingStartServer = false
viewModel.toggleServer()
}
if (pendingDirectoryPicker && hasAllFilesAccess()) {
pendingDirectoryPicker = false
openDocumentTreeLauncher.launch(null)
}
}
private fun requestNotificationPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun maybeShowBatteryOptimizationDialog() {
if (batteryOptimizationDialogShown || isIgnoringBatteryOptimizations()) {
return
}
batteryOptimizationDialogShown = true
AlertDialog.Builder(this)
.setTitle("允许后台运行")
.setMessage("当前还未为本应用开启后台保活相关设置。建议手动在系统设置中开启忽略电池优化、自启动和后台运行。服务现在仍可正常启动;如果在国产系统上退到后台后仍会断开,还需要在最近任务中将本应用锁定。")
.setPositiveButton("知道了", null)
.show()
}
private fun openDataDirectoryPicker() {
if (!hasAllFilesAccess()) {
pendingDirectoryPicker = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续选择目录")
openAllFilesAccessSettings()
return
}
openDocumentTreeLauncher.launch(null)
}
private fun handleToggleServer() {
if (viewModel.uiState.value.status.running) {
viewModel.toggleServer()
return
}
if (!hasAllFilesAccess()) {
pendingStartServer = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续启动服务")
openAllFilesAccessSettings()
return
}
viewModel.toggleServer()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true
}
val powerManager = getSystemService(PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(packageName) == true
}
private fun openLocalUrl() {
val localUrl = viewModel.uiState.value.status.localUrl
if (localUrl.isBlank()) {
viewModel.showMessage("当前没有可打开的本地地址")
return
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
viewModel.showMessage("没有可用于打开本地地址的浏览器")
}
}
private fun openExportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
exportBackupLauncher.launch("honoka-data.db")
}
private fun openImportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
importBackupLauncher.launch(arrayOf("*/*"))
}
private fun openAllFilesAccessSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
val uri = Uri.parse("package:$packageName")
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
}
private fun hasAllFilesAccess(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
}
private fun ensureBackupOperationAllowed(): Boolean {
if (viewModel.uiState.value.status.running) {
viewModel.showMessage("请先停止服务后再导入或导出备份")
return false
}
return true
}
private fun handleSelectedDirectory(uri: Uri?) {
if (uri == null) {
return
}
runCatching {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
val path = treeUriToPath(uri)
if (path != null) {
viewModel.setDataDirPath(path)
} else {
viewModel.showMessage("无法解析所选目录,请改用外部存储目录")
}
}
private fun treeUriToPath(uri: Uri): String? {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val parts = documentId.split(":", limit = 2)
val volume = parts.getOrNull(0) ?: return null
val relativePath = parts.getOrNull(1).orEmpty()
val basePath = when {
volume.equals("primary", ignoreCase = true) -> Environment.getExternalStorageDirectory().absolutePath
else -> "/storage/$volume"
}
return if (relativePath.isBlank()) {
basePath
} else {
File(basePath, relativePath).absolutePath
}
}
private fun exportBackupToUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = true)
checkpointUserDatabase(dataDbFile)
contentResolver.openOutputStream(uri)?.use { output ->
dataDbFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: error("无法打开导出目标文件")
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导出")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导出备份失败")
}
}
}
}
private fun confirmImportBackup(uri: Uri) {
AlertDialog.Builder(this)
.setTitle("导入备份")
.setMessage("导入备份会覆盖当前用户数据,是否继续?")
.setNegativeButton("取消", null)
.setPositiveButton("继续") { _, _ ->
importBackupFromUri(uri)
}
.show()
}
private fun importBackupFromUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = false)
val tempFile = File(dataDbFile.parentFile, "${dataDbFile.name}.importing")
contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: error("无法打开备份文件")
deleteUserDatabaseSidecars(dataDbFile)
if (dataDbFile.exists()) {
dataDbFile.delete()
}
if (!tempFile.renameTo(dataDbFile)) {
tempFile.inputStream().use { input ->
dataDbFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile.delete()
}
deleteUserDatabaseSidecars(dataDbFile)
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导入,下次启动服务时生效")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导入备份失败")
}
}
}
}
private fun ensureRuntimeDataDbFile(requireExisting: Boolean): File {
val runtimeInfo = runtimePreparer.prepare(viewModel.uiState.value.dataDirPath, forceRedeploy = false)
.getOrElse { throw it }
val dataDbFile = File(runtimeInfo.runtimeRoot, "assets/data.db")
dataDbFile.parentFile?.mkdirs()
if (requireExisting && !dataDbFile.exists()) {
throw IllegalStateException("当前没有可用的用户数据文件")
}
return dataDbFile
}
private fun checkpointUserDatabase(dataDbFile: File) {
val db = SQLiteDatabase.openDatabase(dataDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.use {
it.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor ->
while (cursor.moveToNext()) {
}
}
}
}
private fun deleteUserDatabaseSidecars(dataDbFile: File) {
File(dataDbFile.parentFile, "${dataDbFile.name}-wal").delete()
File(dataDbFile.parentFile, "${dataDbFile.name}-shm").delete()
}
}
@@ -0,0 +1,137 @@
package me.killkiss.honokactrl.jni
import org.json.JSONObject
data class NativeServerStatus(
val running: Boolean = false,
val workDir: String = "",
val listenPort: String = "",
val localUrl: String = "",
val startedAt: String = "",
val lastError: String = "",
)
data class NativeReloadResult(
val success: Boolean,
val message: String,
val reloadedAt: String = "",
)
data class NativeHealthInfo(
val status: String = "",
val appName: String = "",
val version: String = "",
val startedAt: String = "",
val uptimeSeconds: Long = 0,
val lastReloadAt: String = "",
val listenPort: String = "",
val cdnServer: String = "",
val reloadTokenConfigured: Boolean = false,
val mainDb: String = "",
val userDb: String = "",
val message: String = "",
)
object NativeBridge {
private val loadError: String? by lazy {
runCatching { System.loadLibrary("honoka_android") }
.exceptionOrNull()
?.message
}
private external fun nativeStart(workDir: String): String?
private external fun nativeStop(): String?
private external fun nativeStatusJson(): String
private external fun nativeHealthJson(): String
private external fun nativeReload(): String?
fun start(workDir: String): Result<Unit> {
val error = ensureLoaded() ?: nativeStart(workDir)
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun stop(): Result<Unit> {
val error = ensureLoaded() ?: nativeStop()
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun status(): NativeServerStatus {
val error = ensureLoaded()
if (error != null) {
return NativeServerStatus(lastError = error)
}
return runCatching {
val json = JSONObject(nativeStatusJson())
NativeServerStatus(
running = json.optBoolean("running"),
workDir = json.optString("work_dir"),
listenPort = json.optString("listen_port"),
localUrl = json.optString("local_url"),
startedAt = json.optString("started_at"),
lastError = json.optString("last_error"),
)
}.getOrElse {
NativeServerStatus(lastError = it.message ?: "failed to parse native status")
}
}
private fun healthJson(): String {
val error = ensureLoaded()
if (error != null) {
return """{"status":"error","message":${JSONObject.quote(error)}}"""
}
return nativeHealthJson()
}
fun health(): NativeHealthInfo {
val raw = healthJson()
return runCatching {
val json = JSONObject(raw)
NativeHealthInfo(
status = json.optString("status"),
appName = json.optString("app_name"),
version = json.optString("version"),
startedAt = json.optString("started_at"),
uptimeSeconds = json.optLong("uptime_seconds"),
lastReloadAt = json.optString("last_reload_at"),
listenPort = json.optString("listen_port"),
cdnServer = json.optString("cdn_server"),
reloadTokenConfigured = json.optBoolean("reload_token_configured"),
mainDb = json.optString("main_db"),
userDb = json.optString("user_db"),
message = json.optString("message"),
)
}.getOrElse {
NativeHealthInfo(
status = "error",
message = it.message ?: "failed to parse health response",
)
}
}
fun reload(): NativeReloadResult {
val error = ensureLoaded()
if (error != null) {
return NativeReloadResult(success = false, message = error)
}
val result = nativeReload()
if (result == null) {
return NativeReloadResult(success = true, message = "configuration reloaded")
}
return runCatching {
val json = JSONObject(result)
NativeReloadResult(
success = json.optString("status") == "ok",
message = json.optString("message").ifBlank { "reload failed" },
reloadedAt = json.optString("reloaded_at"),
)
}.getOrElse {
NativeReloadResult(success = false, message = it.message ?: "reload failed")
}
}
private fun ensureLoaded(): String? = loadError
}
@@ -0,0 +1,168 @@
package me.killkiss.honokactrl.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import me.killkiss.honokactrl.MainActivity
import me.killkiss.honokactrl.R
import me.killkiss.honokactrl.jni.NativeBridge
class HonokaServerService : Service() {
companion object {
private const val TAG = "HonokaServer"
private var wakeLock: PowerManager.WakeLock? = null
private const val CHANNEL_ID = "honoka_server"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "me.killkiss.honokactrl.action.START"
const val ACTION_STOP = "me.killkiss.honokactrl.action.STOP"
const val EXTRA_RUNTIME_ROOT = "runtime_root"
fun startIntent(context: Context, runtimeRoot: String): Intent {
return Intent(context, HonokaServerService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_RUNTIME_ROOT, runtimeRoot)
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, HonokaServerService::class.java).apply {
action = ACTION_STOP
}
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var runtimeRoot: String? = null
override fun onCreate() {
super.onCreate()
Log.i(TAG, "service created")
ensureChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand: action=${intent?.action}, startId=$startId")
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, buildNotification("正在启动服务"))
acquireWakeLock()
runtimeRoot = intent.getStringExtra(EXTRA_RUNTIME_ROOT)
if (runtimeRoot.isNullOrBlank()) {
Log.e(TAG, "missing runtime root")
updateNotification("缺少运行目录")
stopSelf()
return START_REDELIVER_INTENT
}
scope.launch {
Log.i(TAG, "native start begin, runtimeRoot=$runtimeRoot")
val result = NativeBridge.start(runtimeRoot!!)
if (result.isSuccess) {
Log.i(TAG, "native start success")
updateNotification("服务运行中")
} else {
Log.e(TAG, "native start failed", result.exceptionOrNull())
updateNotification(result.exceptionOrNull()?.message ?: "启动失败")
releaseWakeLock()
stopSelf()
}
}
}
ACTION_STOP -> {
scope.launch {
Log.i(TAG, "native stop begin")
NativeBridge.stop()
Log.i(TAG, "native stop finished")
updateNotification("服务已停止")
stopForeground(STOP_FOREGROUND_REMOVE)
releaseWakeLock()
stopSelf()
}
return START_NOT_STICKY
}
}
return START_REDELIVER_INTENT
}
override fun onDestroy() {
Log.i(TAG, "service destroyed")
scope.cancel()
releaseWakeLock()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(content: String): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_server)
.setContentTitle(getString(R.string.app_name))
.setContentText(content)
.setContentIntent(openIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(content: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
"Honoka Server",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "honoka-chan background service"
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "honoka:server").apply {
setReferenceCounted(false)
acquire()
}
Log.i(TAG, "wakelock acquired")
}
private fun releaseWakeLock() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
Log.i(TAG, "wakelock released")
}
}
@@ -0,0 +1,164 @@
package me.killkiss.honokactrl.storage
import android.content.Context
import android.os.Build
import android.system.Os
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Files
import java.security.MessageDigest
import java.util.zip.ZipInputStream
class RuntimePreparer(private val context: Context) {
fun prepare(dataDirPath: String, forceRedeploy: Boolean = false): Result<RuntimeInfo> {
return runCatching {
val runtimeRoot = File(context.filesDir, RUNTIME_DIR_NAME)
val bundleHash = calculateBundleHash()
Log.i(TAG, "prepare runtime root=${runtimeRoot.absolutePath}, dataDir=$dataDirPath, forceRedeploy=$forceRedeploy")
if (forceRedeploy || shouldRedeployBundle(runtimeRoot, bundleHash)) {
redeployBundle(runtimeRoot, bundleHash)
}
mountAndroidDir(runtimeRoot, File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }))
RuntimeInfo(
runtimeRoot = runtimeRoot,
mountedDataDir = File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }),
configFile = File(runtimeRoot, "config.json"),
deployedBundleHash = bundleHash,
)
}
}
private fun redeployBundle(runtimeRoot: File, bundleHash: String) {
Log.i(TAG, "redeploy bundle into ${runtimeRoot.absolutePath}")
val preservedFiles = PRESERVED_RUNTIME_FILES.mapNotNull { relativePath ->
File(runtimeRoot, relativePath).takeIf { it.exists() }?.let { relativePath to it.readBytes() }
}.toMap()
if (runtimeRoot.exists()) {
runtimeRoot.deleteRecursively()
}
runtimeRoot.mkdirs()
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val outFile = File(runtimeRoot, entry.name)
if (preservedFiles.containsKey(entry.name)) {
zip.closeEntry()
entry = zip.nextEntry
continue
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zip.copyTo(output)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
preservedFiles.forEach { (relativePath, data) ->
File(runtimeRoot, relativePath).apply {
parentFile?.mkdirs()
writeBytes(data)
}
}
File(runtimeRoot, BUNDLE_MARKER_FILE).writeText(bundleHash)
Log.i(TAG, "bundle deployed successfully")
}
private fun shouldRedeployBundle(runtimeRoot: File, bundleHash: String): Boolean {
val markerFile = File(runtimeRoot, BUNDLE_MARKER_FILE)
if (!markerFile.exists()) {
return true
}
val deployedHash = runCatching { markerFile.readText().trim() }.getOrDefault("")
val changed = deployedHash != bundleHash
if (changed) {
Log.i(TAG, "runtime bundle hash changed, redeploy required")
}
return changed
}
private fun calculateBundleHash(): String {
val digest = MessageDigest.getInstance("SHA-256")
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) {
break
}
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun mountAndroidDir(runtimeRoot: File, targetDir: File) {
if (!targetDir.exists()) {
targetDir.mkdirs()
}
Log.i(TAG, "mount Android dir target=${targetDir.absolutePath}")
val androidDir = File(runtimeRoot, "static/Android")
androidDir.parentFile?.mkdirs()
if (isSymlink(androidDir)) {
androidDir.delete()
} else if (androidDir.exists()) {
androidDir.deleteRecursively()
}
Os.symlink(targetDir.absolutePath, androidDir.absolutePath)
Log.i(TAG, "symlink created: ${androidDir.absolutePath} -> ${targetDir.absolutePath}")
}
private fun isSymlink(file: File): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.isSymbolicLink(file.toPath())
} else {
try {
file.canonicalFile != file.absoluteFile
} catch (_: IOException) {
false
}
}
}
companion object {
private const val TAG = "HonokaServer"
const val BUNDLE_ASSET_NAME = "honoka_runtime.zip"
const val RUNTIME_DIR_NAME = "honoka-runtime"
private const val BUNDLE_MARKER_FILE = ".bundle_ready"
private val PRESERVED_RUNTIME_FILES = listOf(
"config.json",
"assets/data.db",
"assets/data.db-wal",
"assets/data.db-shm",
)
fun readDeployedBundleHash(runtimeRoot: File): String {
return runCatching {
File(runtimeRoot, BUNDLE_MARKER_FILE).takeIf { it.exists() }?.readText()?.trim().orEmpty()
}.getOrDefault("")
}
}
}
data class RuntimeInfo(
val runtimeRoot: File,
val mountedDataDir: File,
val configFile: File,
val deployedBundleHash: String,
)
@@ -0,0 +1,19 @@
package me.killkiss.honokactrl.storage
import android.content.Context
class SettingsStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun loadDataDirPath(): String = prefs.getString(KEY_DATA_DIR_PATH, DEFAULT_DATA_DIR_PATH) ?: DEFAULT_DATA_DIR_PATH
fun saveDataDirPath(path: String) {
prefs.edit().putString(KEY_DATA_DIR_PATH, path).apply()
}
companion object {
private const val PREFS_NAME = "honoka_control"
private const val KEY_DATA_DIR_PATH = "data_dir_path"
const val DEFAULT_DATA_DIR_PATH = "/sdcard/Download/data"
}
}
@@ -0,0 +1,46 @@
package me.killkiss.honokactrl.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val HonokaLightScheme = lightColorScheme(
primary = Color(0xFFB73E3E),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDAD6),
onPrimaryContainer = Color(0xFF410006),
secondary = Color(0xFF775651),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFDAD6),
onSecondaryContainer = Color(0xFF2C1512),
background = Color(0xFFFFF8F6),
onBackground = Color(0xFF241917),
surface = Color(0xFFFFF8F6),
onSurface = Color(0xFF241917),
)
private val HonokaDarkScheme = darkColorScheme(
primary = Color(0xFFFFB4AB),
onPrimary = Color(0xFF69000F),
primaryContainer = Color(0xFF93001D),
onPrimaryContainer = Color(0xFFFFDAD6),
secondary = Color(0xFFE7BDB7),
onSecondary = Color(0xFF442925),
secondaryContainer = Color(0xFF5D3F3A),
onSecondaryContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1110),
onBackground = Color(0xFFF1DFDC),
surface = Color(0xFF1A1110),
onSurface = Color(0xFFF1DFDC),
)
@Composable
fun HonokaControlTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) HonokaDarkScheme else HonokaLightScheme,
content = content,
)
}
@@ -0,0 +1,275 @@
package me.killkiss.honokactrl.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import me.killkiss.honokactrl.R
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
onToggleServer: () -> Unit,
onOpenLocalUrl: () -> Unit,
onPickDataDirectory: () -> Unit,
onExportBackup: () -> Unit,
onImportBackup: () -> Unit,
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
CenterAlignedTopAppBar(title = { Text(stringResource(R.string.app_name)) })
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("服务控制", style = MaterialTheme.typography.titleMedium)
Text(
if (state.status.running) "当前状态:运行中" else "当前状态:已停止",
style = MaterialTheme.typography.bodyLarge,
)
if (state.status.localUrl.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "本地地址:${state.status.localUrl}",
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenLocalUrl, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Outlined.Language,
contentDescription = "打开本地地址",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
)
}
}
}
if (state.status.startedAt.isNotBlank()) {
Text("启动时间:${state.status.startedAt}")
}
if (state.status.lastError.isNotBlank()) {
Text("最近错误:${state.status.lastError}", color = MaterialTheme.colorScheme.error)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onToggleServer,
enabled = !state.busy,
modifier = Modifier.widthIn(min = 132.dp),
) {
Icon(
if (state.status.running) Icons.Outlined.Stop else Icons.Outlined.PlayArrow,
contentDescription = null,
)
Text(if (state.status.running) "停止服务" else "启动服务")
}
OutlinedButton(
onClick = { viewModel.refreshStatus() },
enabled = !state.busy,
modifier = Modifier.widthIn(min = 132.dp),
) {
Icon(Icons.Outlined.Refresh, contentDescription = null)
Text("刷新状态")
}
}
}
}
if (state.message.isNotBlank()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = state.message,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("服务设置", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("解锁全部日替", style = MaterialTheme.typography.bodyLarge)
Text(
"切换后会直接写入配置文件,并在服务运行中自动重载。",
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = state.unlockAllSpecialRotation,
onCheckedChange = { viewModel.updateUnlockAllSpecialRotation(it) },
enabled = !state.busy,
)
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("数据目录", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.dataDirPath,
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
label = { Text("Android 数据目录") },
leadingIcon = { Icon(Icons.Outlined.Storage, contentDescription = null) },
readOnly = true,
singleLine = true,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onPickDataDirectory) {
Icon(Icons.Outlined.FolderOpen, contentDescription = null)
Text("选择目录")
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("备份管理", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onExportBackup) {
Text("导出备份")
}
OutlinedButton(onClick = onImportBackup) {
Text("导入备份")
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("运行时信息", style = MaterialTheme.typography.titleMedium)
LabelValue(label = "运行目录", value = state.runtimeRoot)
LabelValue(label = "配置文件", value = state.configPath)
LabelValue(label = "挂载目录", value = state.dataDirPath)
LabelValue(label = "资源包哈希", value = state.deployedBundleHash)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Health 状态", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = state.health.status == "ok",
onClick = {},
label = { Text(if (state.health.status.isBlank()) "未知" else state.health.status) },
)
FilterChip(
selected = state.health.mainDb == "ok",
onClick = {},
label = { Text("Main DB: ${if (state.health.mainDb == "ok") "connected" else "disconnected"}") },
)
FilterChip(
selected = state.health.userDb == "ok",
onClick = {},
label = { Text("User DB: ${if (state.health.userDb == "ok") "connected" else "disconnected"}") },
)
}
LabelValue(label = "应用名", value = state.health.appName)
LabelValue(label = "版本", value = state.health.version)
LabelValue(label = "监听端口", value = state.health.listenPort)
LabelValue(label = "启动时间", value = state.health.startedAt)
LabelValue(label = "运行时长", value = formatUptime(state.health.uptimeSeconds))
LabelValue(label = "最近重载", value = state.health.lastReloadAt)
LabelValue(label = "CDN", value = state.health.cdnServer)
LabelValue(
label = "Reload Token",
value = if (state.health.reloadTokenConfigured) "已配置" else "未配置",
)
if (state.health.message.isNotBlank()) {
Text(
text = state.health.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
@Composable
private fun LabelValue(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(value.ifBlank { "-" }, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatUptime(seconds: Long): String {
if (seconds <= 0) {
return "-"
}
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainSeconds = seconds % 60
return "%02d:%02d:%02d".format(hours, minutes, remainSeconds)
}
@@ -0,0 +1,390 @@
package me.killkiss.honokactrl.ui
import android.app.Application
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import me.killkiss.honokactrl.jni.NativeBridge
import me.killkiss.honokactrl.jni.NativeHealthInfo
import me.killkiss.honokactrl.jni.NativeReloadResult
import me.killkiss.honokactrl.jni.NativeServerStatus
import me.killkiss.honokactrl.service.HonokaServerService
import me.killkiss.honokactrl.storage.RuntimePreparer
import me.killkiss.honokactrl.storage.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
data class MainUiState(
val dataDirPath: String = SettingsStore.DEFAULT_DATA_DIR_PATH,
val runtimeRoot: String = "",
val configPath: String = "",
val deployedBundleHash: String = "",
val allFilesAccessGranted: Boolean = false,
val status: NativeServerStatus = NativeServerStatus(),
val health: NativeHealthInfo = NativeHealthInfo(),
val unlockAllSpecialRotation: Boolean = false,
val message: String = "",
val busy: Boolean = false,
)
class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "HonokaServer"
}
private val settingsStore = SettingsStore(application)
private val runtimePreparer = RuntimePreparer(application)
private var messageClearJob: Job? = null
private val _uiState = MutableStateFlow(
MainUiState(
dataDirPath = settingsStore.loadDataDirPath(),
allFilesAccessGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager(),
)
)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
init {
refreshStatus()
refreshRuntimePaths()
}
fun setDataDirPath(path: String) {
val normalizedPath = path.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }
settingsStore.saveDataDirPath(normalizedPath)
_uiState.value = _uiState.value.copy(dataDirPath = normalizedPath)
applyDataDirMount()
}
fun showMessage(message: String) {
pushMessage(message)
}
fun toggleServer() {
if (uiState.value.status.running) {
stopServer()
} else {
startServer()
}
}
fun startServer() {
if (!uiState.value.allFilesAccessGranted) {
Log.w(TAG, "start aborted: all files access is not granted")
pushMessage("请先授予全部文件访问权限")
return
}
Log.i(TAG, "start requested, dataDir=${uiState.value.dataDirPath}")
prepareRuntime { runtimeRoot ->
val context = getApplication<Application>()
val intent = HonokaServerService.startIntent(context, runtimeRoot)
try {
Log.i(TAG, "starting foreground service, runtimeRoot=$runtimeRoot")
ContextCompat.startForegroundService(context, intent)
_uiState.value = _uiState.value.copy(busy = true)
pushMessage("正在启动服务", autoClearMillis = null)
refreshServerState(
initialDelayMillis = 500,
retryCount = 8,
retryDelayMillis = 400,
expectedRunning = true,
updateMessage = true,
)
} catch (e: Throwable) {
Log.e(TAG, "failed to start foreground service", e)
_uiState.value = _uiState.value.copy(busy = false)
pushMessage(e.message ?: "启动前台服务失败")
}
}
}
fun stopServer() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "stop requested")
_uiState.value = _uiState.value.copy(busy = true)
val context = getApplication<Application>()
context.startService(HonokaServerService.stopIntent(context))
refreshServerState(
initialDelayMillis = 200,
retryCount = 6,
retryDelayMillis = 300,
expectedRunning = false,
updateMessage = true,
)
}
}
fun refreshStatus(delayed: Boolean = false) {
refreshServerState(initialDelayMillis = if (delayed) 800 else 0, updateMessage = false)
}
fun refreshAllFilesAccess(granted: Boolean) {
_uiState.value = _uiState.value.copy(allFilesAccessGranted = granted)
}
fun updateUnlockAllSpecialRotation(enabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = _uiState.value.copy(
busy = true,
unlockAllSpecialRotation = enabled,
)
val configFile = File(_uiState.value.configPath.ifBlank {
File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
.resolve("config.json")
.absolutePath
})
runCatching {
writeUnlockAllSpecialRotation(configFile, enabled)
}.onFailure {
Log.e(TAG, "failed to update config", it)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(
busy = false,
unlockAllSpecialRotation = !enabled,
)
pushMessage(it.message ?: "更新配置失败")
}
return@launch
}
if (!uiState.value.status.running) {
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
pushMessage("设置已保存,下次启动服务时生效")
}
return@launch
}
val result: NativeReloadResult = NativeBridge.reload()
Log.i(TAG, "reload requested by config switch: success=${result.success}, message=${result.message}")
withContext(Dispatchers.Main) {
if (result.success) {
pushMessage("设置已保存并重载")
refreshServerState(initialDelayMillis = 200, updateMessage = false)
} else {
_uiState.value = _uiState.value.copy(
busy = false,
)
pushMessage(result.message.ifBlank { "设置已保存,但重载配置失败" })
}
}
}
}
private fun applyDataDirMount() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "apply data dir mount: ${_uiState.value.dataDirPath}")
_uiState.value = _uiState.value.copy(busy = true)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("数据目录已保存并挂载")
}.onFailure {
Log.e(TAG, "apply data dir mount failed", it)
pushMessage(it.message ?: "目录挂载失败")
}
}
}
}
private fun prepareRuntime(onSuccess: ((String) -> Unit)? = null) {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "prepare runtime")
_uiState.value = _uiState.value.copy(busy = true)
settingsStore.saveDataDirPath(_uiState.value.dataDirPath)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
Log.i(TAG, "runtime ready: root=${it.runtimeRoot}, mounted=${it.mountedDataDir}")
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("运行时已准备完成")
onSuccess?.invoke(it.runtimeRoot.absolutePath)
}.onFailure {
Log.e(TAG, "runtime prepare failed", it)
pushMessage(it.message ?: "运行时准备失败")
}
}
}
}
private fun refreshRuntimePaths() {
val runtimeRoot = File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
_uiState.value = _uiState.value.copy(
runtimeRoot = runtimeRoot.absolutePath,
configPath = File(runtimeRoot, "config.json").absolutePath,
deployedBundleHash = RuntimePreparer.readDeployedBundleHash(runtimeRoot),
)
syncConfigState(File(runtimeRoot, "config.json"))
}
private fun refreshServerState(
initialDelayMillis: Long = 0,
retryCount: Int = 1,
retryDelayMillis: Long = 0,
expectedRunning: Boolean? = null,
updateMessage: Boolean = false,
) {
viewModelScope.launch(Dispatchers.IO) {
if (initialDelayMillis > 0) {
delay(initialDelayMillis)
}
var status = NativeBridge.status()
var health = NativeBridge.health()
for (attempt in 1 until retryCount.coerceAtLeast(1)) {
if (isExpectedStateReached(status, health, expectedRunning)) {
break
}
if (retryDelayMillis > 0) {
delay(retryDelayMillis)
}
status = NativeBridge.status()
health = NativeBridge.health()
}
Log.d(
TAG,
"server state refreshed: running=${status.running}, health=${health.status}, mainDb=${health.mainDb}, userDb=${health.userDb}, error=${status.lastError}",
)
withContext(Dispatchers.Main) {
val message = if (updateMessage) {
buildStatusMessage(status, health, expectedRunning)
} else {
_uiState.value.message
}
_uiState.value = _uiState.value.copy(
status = status,
health = health,
busy = false,
message = if (updateMessage) message else _uiState.value.message,
)
syncConfigState(File(_uiState.value.configPath))
if (updateMessage && message.isNotBlank()) {
scheduleMessageClear(5000)
}
refreshRuntimePaths()
}
}
}
private fun isExpectedStateReached(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): Boolean {
return when (expectedRunning) {
true -> status.running && health.status != "stopped" && health.mainDb != "not initialized" && health.userDb != "not initialized"
false -> !status.running
null -> true
}
}
private fun buildStatusMessage(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): String {
if (status.lastError.isNotBlank()) {
return status.lastError
}
return when (expectedRunning) {
true -> {
if (status.running && health.status != "stopped") {
"服务运行中"
} else {
"服务启动中,请稍后刷新"
}
}
false -> "服务已停止"
null -> {
if (status.running) {
"服务运行中"
} else {
_uiState.value.message
}
}
}
}
private fun pushMessage(message: String, autoClearMillis: Long? = 5000) {
messageClearJob?.cancel()
_uiState.value = _uiState.value.copy(message = message)
if (autoClearMillis != null) {
scheduleMessageClear(autoClearMillis)
}
}
private fun syncConfigState(configFile: File) {
if (!configFile.exists()) {
return
}
runCatching {
val json = JSONObject(configFile.readText())
json.optJSONObject("settings")?.optBoolean("unlock_all_special_rotation", false) ?: false
}.onSuccess { enabled ->
_uiState.value = _uiState.value.copy(unlockAllSpecialRotation = enabled)
}.onFailure {
Log.w(TAG, "failed to parse config state", it)
}
}
private fun writeUnlockAllSpecialRotation(configFile: File, enabled: Boolean) {
configFile.parentFile?.mkdirs()
val json = if (configFile.exists()) {
runCatching { JSONObject(configFile.readText()) }.getOrElse { JSONObject() }
} else {
JSONObject()
}
val settings = json.optJSONObject("settings") ?: JSONObject()
if (!json.has("app_name")) {
json.put("app_name", "honoka-chan")
}
settings.put("listen_port", settings.optString("listen_port", "8080"))
settings.put("cdn_server", settings.optString("cdn_server", "http://127.0.0.1:8080/static"))
settings.put("reload_token", settings.optString("reload_token", ""))
settings.put("unlock_all_special_rotation", enabled)
json.put("settings", settings)
configFile.writeText(json.toString(4) + "\n")
}
private fun scheduleMessageClear(delayMillis: Long) {
messageClearJob?.cancel()
messageClearJob = viewModelScope.launch {
delay(delayMillis)
_uiState.value = _uiState.value.copy(message = "")
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A73E8"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">honoka-chan</string>
</resources>
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.HonokaControl" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>