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>
@@ -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>
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
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>
|
||||