mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 19:51:58 +08:00
feat: 新增安卓平台支持
This commit is contained in:
36
android/app/src/main/AndroidManifest.xml
Normal file
36
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher_one_kvm"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme">
|
||||
<service
|
||||
android:name=".OneKvmService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<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>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED && HostSettings.getAutoStart(context)) {
|
||||
OneKvmService.start(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object HostSettings {
|
||||
private const val PREFS = "one_kvm_android"
|
||||
private const val KEY_AUTO_START = "auto_start"
|
||||
private const val KEY_CLEAR_EXISTING_OTG = "clear_existing_otg"
|
||||
|
||||
fun getAutoStart(context: Context): Boolean {
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_AUTO_START, false)
|
||||
}
|
||||
|
||||
fun setAutoStart(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_AUTO_START, enabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getClearExistingOtg(context: Context): Boolean {
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_CLEAR_EXISTING_OTG, false)
|
||||
}
|
||||
|
||||
fun setClearExistingOtg(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_CLEAR_EXISTING_OTG, enabled)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object LogConfig {
|
||||
private const val PREFS = "one_kvm_android"
|
||||
private const val KEY_LOG_LEVEL = "log_level"
|
||||
const val DEFAULT_LEVEL = "info"
|
||||
val LEVELS = arrayOf("error", "warn", "info", "debug", "trace")
|
||||
|
||||
fun getLevel(context: Context): String {
|
||||
val value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_LOG_LEVEL, DEFAULT_LEVEL)
|
||||
?: DEFAULT_LEVEL
|
||||
return if (LEVELS.contains(value)) value else DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
fun setLevel(context: Context, level: String) {
|
||||
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_LOG_LEVEL, safeLevel)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun rustLogFilter(level: String): String {
|
||||
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
|
||||
return "one_kvm=$safeLevel,hwcodec=$safeLevel,tower_http=$safeLevel,webrtc_sctp=warn"
|
||||
}
|
||||
}
|
||||
71
android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt
Normal file
71
android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object LogStore {
|
||||
private const val FLUSH_DELAY_MS = 250L
|
||||
private const val MAX_BUFFER_CHARS = 64 * 1024
|
||||
|
||||
private val lock = Any()
|
||||
private val buffer = StringBuilder()
|
||||
private val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "OneKvmLogStore").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
private var logFile: File? = null
|
||||
private var flushScheduled = false
|
||||
|
||||
fun defaultLogFile(context: Context): File {
|
||||
return File(File(context.getExternalFilesDir(null), "runtime"), "one-kvm.log")
|
||||
}
|
||||
|
||||
fun configure(file: File) {
|
||||
synchronized(lock) {
|
||||
flushLocked()
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText("")
|
||||
buffer.clear()
|
||||
logFile = file
|
||||
flushScheduled = false
|
||||
}
|
||||
}
|
||||
|
||||
fun append(line: String) {
|
||||
synchronized(lock) {
|
||||
if (logFile == null) return
|
||||
|
||||
buffer.append(line).append('\n')
|
||||
if (buffer.length >= MAX_BUFFER_CHARS) {
|
||||
flushLocked()
|
||||
return
|
||||
}
|
||||
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true
|
||||
executor.schedule({ flush() }, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flush() {
|
||||
synchronized(lock) {
|
||||
flushLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushLocked() {
|
||||
val file = logFile ?: return
|
||||
if (buffer.isEmpty()) {
|
||||
flushScheduled = false
|
||||
return
|
||||
}
|
||||
|
||||
val text = buffer.toString()
|
||||
buffer.clear()
|
||||
flushScheduled = false
|
||||
file.appendText(text)
|
||||
}
|
||||
}
|
||||
452
android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt
Normal file
452
android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt
Normal file
@@ -0,0 +1,452 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.Spinner
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Socket
|
||||
import java.util.Collections
|
||||
|
||||
class MainActivity : Activity() {
|
||||
private lateinit var statusValue: TextView
|
||||
private lateinit var hostActionButton: Button
|
||||
private lateinit var logLevelSpinner: Spinner
|
||||
private lateinit var autoStartSwitch: Switch
|
||||
private lateinit var clearOtgSwitch: Switch
|
||||
private val statusHandler = Handler(Looper.getMainLooper())
|
||||
private var statusPollsRemaining = 0
|
||||
private val statusPoller = object : Runnable {
|
||||
override fun run() {
|
||||
refreshStatus()
|
||||
statusPollsRemaining -= 1
|
||||
if (statusPollsRemaining > 0) {
|
||||
statusHandler.postDelayed(this, STATUS_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
window.statusBarColor = color("#F8FAFC")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
window.navigationBarColor = color("#F8FAFC")
|
||||
}
|
||||
|
||||
val content = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(20.dp(), 24.dp(), 20.dp(), 28.dp())
|
||||
background = solid("#F8FAFC")
|
||||
}
|
||||
|
||||
content.addView(startCard())
|
||||
content.addView(settingsCard())
|
||||
content.addView(infoCard())
|
||||
|
||||
setContentView(ScrollView(this).apply {
|
||||
isFillViewport = true
|
||||
setBackgroundColor(color("#F8FAFC"))
|
||||
addView(content)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
reconcilePersistedStatus()
|
||||
refreshStatus()
|
||||
autoStartSwitch.isChecked = HostSettings.getAutoStart(this)
|
||||
clearOtgSwitch.isChecked = HostSettings.getClearExistingOtg(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
statusHandler.removeCallbacks(statusPoller)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun startCard(): View {
|
||||
return card {
|
||||
addView(sectionTitle("启动管理"))
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "管理本机 One-KVM 服务进程。暂停会停止前台服务并释放运行资源。"
|
||||
textSize = 14f
|
||||
setTextColor(color("#64748B"))
|
||||
setPadding(0, 6.dp(), 0, 14.dp())
|
||||
})
|
||||
|
||||
statusValue = TextView(this@MainActivity).apply {
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(color("#0F172A"))
|
||||
background = rounded("#EFF6FF", "#BFDBFE", 8)
|
||||
setPadding(12.dp(), 8.dp(), 12.dp(), 8.dp())
|
||||
}
|
||||
addView(statusValue, matchWrap())
|
||||
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 14.dp(), 0, 0)
|
||||
hostActionButton = actionButton("启动", primary = true) { toggleHost() }
|
||||
addView(hostActionButton, matchButton())
|
||||
})
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun settingsCard(): View {
|
||||
return card {
|
||||
addView(sectionTitle("运行设置"))
|
||||
|
||||
val (autoStartRow, autoStartControl) = settingSwitchRow(
|
||||
title = "开机自启动",
|
||||
subtitle = "系统启动完成后自动拉起 One-KVM 前台服务。",
|
||||
checked = HostSettings.getAutoStart(this@MainActivity),
|
||||
) { _, checked ->
|
||||
HostSettings.setAutoStart(this@MainActivity, checked)
|
||||
LogStore.append("Boot auto-start ${if (checked) "enabled" else "disabled"}")
|
||||
}
|
||||
autoStartSwitch = autoStartControl
|
||||
addView(autoStartRow)
|
||||
|
||||
addView(divider())
|
||||
|
||||
val (clearOtgRow, clearOtgControl) = settingSwitchRow(
|
||||
title = "清除已有 OTG Gadget",
|
||||
subtitle = "启动 root host 前尝试解绑并删除 configfs 中已有的 USB gadget。",
|
||||
checked = HostSettings.getClearExistingOtg(this@MainActivity),
|
||||
) { _, checked ->
|
||||
HostSettings.setClearExistingOtg(this@MainActivity, checked)
|
||||
LogStore.append("Clear existing OTG gadget ${if (checked) "enabled" else "disabled"}")
|
||||
}
|
||||
clearOtgSwitch = clearOtgControl
|
||||
addView(clearOtgRow)
|
||||
|
||||
addView(divider())
|
||||
addView(logLevelRow())
|
||||
}
|
||||
}
|
||||
|
||||
private fun infoCard(): View {
|
||||
return card {
|
||||
addView(sectionTitle("应用信息"))
|
||||
addView(infoRow("软件内核版本", kernelVersion()))
|
||||
addView(infoRow("访问地址", accessAddresses(), selectable = true))
|
||||
addView(infoRow("日志文件", LogStore.defaultLogFile(this@MainActivity).absolutePath, selectable = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun settingSwitchRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
listener: CompoundButton.OnCheckedChangeListener,
|
||||
): Pair<View, Switch> {
|
||||
val switch = Switch(this).apply {
|
||||
isChecked = checked
|
||||
setOnCheckedChangeListener(listener)
|
||||
}
|
||||
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 12.dp(), 0, 12.dp())
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = title
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(color("#0F172A"))
|
||||
})
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = subtitle
|
||||
textSize = 13f
|
||||
setTextColor(color("#64748B"))
|
||||
setPadding(0, 4.dp(), 12.dp(), 0)
|
||||
})
|
||||
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
|
||||
addView(switch)
|
||||
}
|
||||
|
||||
return row to switch
|
||||
}
|
||||
|
||||
private fun infoRow(label: String, value: String, selectable: Boolean = false): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, 12.dp(), 0, 12.dp())
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = label
|
||||
textSize = 13f
|
||||
setTextColor(color("#64748B"))
|
||||
})
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = value
|
||||
textSize = 15f
|
||||
setTextColor(color("#0F172A"))
|
||||
setPadding(0, 4.dp(), 0, 0)
|
||||
setTextIsSelectable(selectable)
|
||||
})
|
||||
addView(divider())
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLevelRow(): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 12.dp(), 0, 0)
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "日志级别"
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(color("#0F172A"))
|
||||
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
|
||||
|
||||
logLevelSpinner = Spinner(this@MainActivity).apply {
|
||||
adapter = ArrayAdapter(
|
||||
this@MainActivity,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
LogConfig.LEVELS,
|
||||
)
|
||||
setSelection(LogConfig.LEVELS.indexOf(LogConfig.getLevel(this@MainActivity)).coerceAtLeast(0))
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val level = LogConfig.LEVELS[position]
|
||||
if (level != LogConfig.getLevel(this@MainActivity)) {
|
||||
LogConfig.setLevel(this@MainActivity, level)
|
||||
LogStore.append("Log level set to $level; restart service to apply")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
}
|
||||
addView(logLevelSpinner)
|
||||
}
|
||||
}
|
||||
|
||||
private fun card(build: LinearLayout.() -> Unit): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp(), 16.dp(), 16.dp(), 16.dp())
|
||||
background = rounded("#FFFFFF", "#E2E8F0", 10)
|
||||
elevation = 1.5f.dpFloat()
|
||||
build()
|
||||
}.also {
|
||||
it.layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
).apply { setMargins(0, 0, 0, 14.dp()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(text: String): View {
|
||||
return TextView(this).apply {
|
||||
this.text = text
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(color("#0F172A"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionButton(text: String, primary: Boolean, action: () -> Unit): Button {
|
||||
return Button(this).apply {
|
||||
this.text = text
|
||||
textSize = 15f
|
||||
isAllCaps = false
|
||||
minHeight = 44.dp()
|
||||
setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
|
||||
background = if (primary) rounded("#2563EB", "#2563EB", 8) else rounded("#FFFFFF", "#CBD5E1", 8)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleHost() {
|
||||
when (ServiceStatusStore.snapshot(this).state) {
|
||||
ServiceStatusStore.STATE_RUNNING -> pauseHost()
|
||||
ServiceStatusStore.STATE_STOPPED, ServiceStatusStore.STATE_ERROR -> startHost()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHost() {
|
||||
ServiceStatusStore.setStarting(this)
|
||||
refreshStatus()
|
||||
OneKvmService.start(this)
|
||||
LogStore.append("Start requested from app UI")
|
||||
pollStatusForAWhile()
|
||||
}
|
||||
|
||||
private fun pauseHost() {
|
||||
ServiceStatusStore.setStopping(this)
|
||||
refreshStatus()
|
||||
OneKvmService.stop(this)
|
||||
LogStore.append("Pause requested from app UI")
|
||||
pollStatusForAWhile()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
if (::statusValue.isInitialized) {
|
||||
statusValue.text = "状态:${hostStatusSummary()}"
|
||||
}
|
||||
updateHostActionButton()
|
||||
}
|
||||
|
||||
private fun hostStatusSummary(): String {
|
||||
val serviceStatus = ServiceStatusStore.snapshot(this)
|
||||
if (serviceStatus.state != ServiceStatusStore.STATE_STOPPED) {
|
||||
return serviceStatus.labelText()
|
||||
}
|
||||
|
||||
val nativeRunning = runCatching {
|
||||
NativeBridge.hostStatus().contains("running", ignoreCase = true)
|
||||
}.getOrDefault(false)
|
||||
|
||||
return if (nativeRunning) "运行中" else "已停止"
|
||||
}
|
||||
|
||||
private fun reconcilePersistedStatus() {
|
||||
val serviceStatus = ServiceStatusStore.snapshot(this)
|
||||
if (serviceStatus.state == ServiceStatusStore.STATE_STOPPED) return
|
||||
if (
|
||||
serviceStatus.state == ServiceStatusStore.STATE_STARTING &&
|
||||
System.currentTimeMillis() - serviceStatus.updatedAt < STARTING_RECONCILE_GRACE_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
Thread {
|
||||
val portOpen = isLocalWebPortOpen()
|
||||
val nativeRunning = runCatching { NativeBridge.hostStatus().contains("running", ignoreCase = true) }
|
||||
.getOrDefault(false)
|
||||
if (!portOpen && !nativeRunning) {
|
||||
ServiceStatusStore.setStopped(this, "服务未运行")
|
||||
runOnUiThread { refreshStatus() }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun isLocalWebPortOpen(): Boolean {
|
||||
return runCatching {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress("127.0.0.1", 8080), 250)
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun updateHostActionButton() {
|
||||
if (!::hostActionButton.isInitialized) return
|
||||
|
||||
when (ServiceStatusStore.snapshot(this).state) {
|
||||
ServiceStatusStore.STATE_STARTING -> setHostActionButton("启动中...", enabled = false, primary = true)
|
||||
ServiceStatusStore.STATE_RUNNING -> setHostActionButton("停止", enabled = true, primary = false)
|
||||
ServiceStatusStore.STATE_STOPPING -> setHostActionButton("停止中...", enabled = false, primary = false)
|
||||
else -> setHostActionButton("启动", enabled = true, primary = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setHostActionButton(text: String, enabled: Boolean, primary: Boolean) {
|
||||
hostActionButton.text = text
|
||||
hostActionButton.isEnabled = enabled
|
||||
hostActionButton.alpha = if (enabled) 1f else 0.65f
|
||||
hostActionButton.setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
|
||||
hostActionButton.background = if (primary) {
|
||||
rounded("#2563EB", "#2563EB", 8)
|
||||
} else {
|
||||
rounded("#FFFFFF", "#CBD5E1", 8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollStatusForAWhile() {
|
||||
statusPollsRemaining = 20
|
||||
statusHandler.removeCallbacks(statusPoller)
|
||||
statusHandler.postDelayed(statusPoller, STATUS_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun kernelVersion(): String {
|
||||
return runCatching { NativeBridge.kernelVersion() }
|
||||
.getOrElse { "unknown" }
|
||||
}
|
||||
|
||||
private fun accessAddresses(): String {
|
||||
val addresses = runCatching {
|
||||
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { iface -> Collections.list(iface.inetAddresses) }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.filter { !it.isLoopbackAddress }
|
||||
.map { "http://${it.hostAddress}:8080" }
|
||||
.distinct()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
return (addresses.ifEmpty { listOf("http://127.0.0.1:8080") }).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun divider(): View {
|
||||
return View(this).apply {
|
||||
setBackgroundColor(color("#E2E8F0"))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
1,
|
||||
).apply { setMargins(0, 0, 0, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchWrap(): LinearLayout.LayoutParams {
|
||||
return LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
|
||||
private fun matchButton(): LinearLayout.LayoutParams {
|
||||
return LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
48.dp(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun solid(hex: String): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color(hex))
|
||||
}
|
||||
|
||||
private fun rounded(fill: String, stroke: String, radiusDp: Int): GradientDrawable {
|
||||
return GradientDrawable().apply {
|
||||
setColor(color(fill))
|
||||
cornerRadius = radiusDp.dpFloat()
|
||||
setStroke(1.dp(), color(stroke))
|
||||
}
|
||||
}
|
||||
|
||||
private fun color(hex: String): Int = Color.parseColor(hex)
|
||||
|
||||
private fun Int.dp(): Int = (this * resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
private fun Int.dpFloat(): Float = this * resources.displayMetrics.density
|
||||
|
||||
private fun Float.dpFloat(): Float = this * resources.displayMetrics.density
|
||||
|
||||
companion object {
|
||||
private const val STATUS_POLL_INTERVAL_MS = 500L
|
||||
private const val STARTING_RECONCILE_GRACE_MS = 15_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object NativeBridge {
|
||||
init {
|
||||
System.loadLibrary("one_kvm_android_bootstrap")
|
||||
}
|
||||
|
||||
external fun initTlsVerifier(context: Context): Int
|
||||
|
||||
external fun setEnv(name: String, value: String): Int
|
||||
|
||||
external fun startHost(dataDir: String, bindAddress: String, port: Int): String
|
||||
|
||||
external fun stopHost(): String
|
||||
|
||||
external fun hostStatus(): String
|
||||
|
||||
external fun kernelVersion(): String
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
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 java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class OneKvmService : Service() {
|
||||
private var rootProcess: Process? = null
|
||||
private val commandExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "OneKvmServiceCommand")
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action ?: ACTION_START) {
|
||||
ACTION_STOP -> {
|
||||
ServiceStatusStore.setStopping(this)
|
||||
commandExecutor.execute {
|
||||
stopHostRuntime()
|
||||
stopSelfResult(startId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_START -> {
|
||||
ServiceStatusStore.setStarting(this)
|
||||
startForegroundCompat(NOTIFICATION_ID, notification("启动中"))
|
||||
commandExecutor.execute {
|
||||
val currentState = ServiceStatusStore.snapshot(this).state
|
||||
if (currentState == ServiceStatusStore.STATE_RUNNING && isPortOpen(8080, 100)) {
|
||||
return@execute
|
||||
}
|
||||
val dataDir = File(getExternalFilesDir(null), "runtime")
|
||||
if (!dataDir.exists()) dataDir.mkdirs()
|
||||
val result = startRustHost(dataDir)
|
||||
if (result.startsWith("Running") && !result.contains("start failed", ignoreCase = true)) {
|
||||
ServiceStatusStore.setRunning(this, "服务已启动")
|
||||
notificationManager().notify(NOTIFICATION_ID, notification("运行中"))
|
||||
} else {
|
||||
ServiceStatusStore.setError(this, "启动失败")
|
||||
notificationManager().notify(NOTIFICATION_ID, notification("启动失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopHostRuntime(updateNotification = false)
|
||||
commandExecutor.shutdownNow()
|
||||
ServiceStatusStore.setStopped(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun notification(state: String): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = createContentIntent(intent)
|
||||
val builder = createNotificationBuilder()
|
||||
return builder
|
||||
.setSmallIcon(R.drawable.ic_stat_one_kvm)
|
||||
.setContentTitle("One-KVM Android Host")
|
||||
.setContentText(state)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"One-KVM Host",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
notificationManager().createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createNotificationBuilder(): Notification.Builder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createContentIntent(intent: Intent): PendingIntent {
|
||||
val flags = pendingIntentFlags()
|
||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int {
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
flags = flags or pendingIntentImmutableFlag()
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
private fun pendingIntentImmutableFlag(): Int {
|
||||
return try {
|
||||
PendingIntent::class.java.getField("FLAG_IMMUTABLE").getInt(null)
|
||||
} catch (_: ReflectiveOperationException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
private fun stopHostRuntime(updateNotification: Boolean = true) {
|
||||
stopRootHost()
|
||||
NativeBridge.stopHost()
|
||||
waitForPortRelease(8080, 2_000)
|
||||
LogStore.flush()
|
||||
ServiceStatusStore.setStopped(this)
|
||||
if (updateNotification) {
|
||||
notificationManager().notify(NOTIFICATION_ID, notification("已停止"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRustHost(dataDir: File): String {
|
||||
val logLevel = LogConfig.getLevel(this)
|
||||
val rustLog = LogConfig.rustLogFilter(logLevel)
|
||||
val appLogFile = LogStore.defaultLogFile(this)
|
||||
LogStore.configure(appLogFile)
|
||||
val rustLogFile = appLogFile
|
||||
LogStore.append("Starting One-KVM Rust host, data_dir=${dataDir.absolutePath}, log_level=$logLevel")
|
||||
val executable = extractHostBinary()
|
||||
return runCatching {
|
||||
val tlsInit = NativeBridge.initTlsVerifier(this)
|
||||
if (tlsInit != 0) {
|
||||
throw IllegalStateException("rustls platform verifier init failed with code $tlsInit")
|
||||
}
|
||||
stopRootHost(executable)
|
||||
clearExistingOtgGadgetsIfEnabled()
|
||||
startRootHost(executable, dataDir, rustLog, rustLogFile, logLevel)
|
||||
LogStore.append("Rust host running as root on port 8080")
|
||||
"Running as root on port 8080"
|
||||
}.getOrElse { rootError ->
|
||||
LogStore.append("Root host unavailable: ${rootError.message ?: rootError::class.java.simpleName}")
|
||||
configureAlsaEnvironment(executable)
|
||||
NativeBridge.setEnv("RUST_LOG", rustLog)
|
||||
NativeBridge.setEnv("ONE_KVM_FFMPEG_LOG", ffmpegLogLevel(logLevel))
|
||||
NativeBridge.setEnv("ONE_KVM_ANDROID_LOG_FILE", rustLogFile.absolutePath)
|
||||
val jniResult = NativeBridge.startHost(dataDir.absolutePath, "0.0.0.0", 8080)
|
||||
LogStore.append("Rust host running in app process on port 8080: $jniResult")
|
||||
"Running in app process on port 8080 (${rootError.message ?: "root unavailable"}; $jniResult)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearExistingOtgGadgetsIfEnabled() {
|
||||
if (!HostSettings.getClearExistingOtg(this)) return
|
||||
|
||||
val command = """
|
||||
root=/sys/kernel/config/usb_gadget
|
||||
[ -d "${'$'}root" ] || exit 0
|
||||
for gadget in "${'$'}root"/*; do
|
||||
[ -d "${'$'}gadget" ] || continue
|
||||
[ -w "${'$'}gadget/UDC" ] && echo "" > "${'$'}gadget/UDC" 2>/dev/null || true
|
||||
find "${'$'}gadget/configs" -type l -delete 2>/dev/null || true
|
||||
rm -rf "${'$'}gadget" 2>/dev/null || true
|
||||
done
|
||||
""".trimIndent()
|
||||
|
||||
runCatching {
|
||||
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
.waitFor()
|
||||
}.onSuccess { exit ->
|
||||
LogStore.append("Existing OTG gadget cleanup finished with exit code $exit")
|
||||
}.onFailure { err ->
|
||||
LogStore.append("Existing OTG gadget cleanup failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureAlsaEnvironment(executable: File) {
|
||||
val binDir = executable.parentFile
|
||||
?: throw IllegalStateException("host binary has no parent directory")
|
||||
val alsaConfigDir = File(binDir, "alsa")
|
||||
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
|
||||
NativeBridge.setEnv("ALSA_CONFIG_DIR", alsaConfigDir.absolutePath)
|
||||
NativeBridge.setEnv("ALSA_CONFIG_PATH", alsaConfigPath.absolutePath)
|
||||
}
|
||||
|
||||
private fun extractHostBinary(): File {
|
||||
val abi = Build.SUPPORTED_ABIS.firstOrNull { it == "arm64-v8a" || it == "armeabi-v7a" }
|
||||
?: throw IllegalStateException("unsupported ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
|
||||
val binDir = File(filesDir, "bin/$abi")
|
||||
val target = File(binDir, "one-kvm-android-host")
|
||||
copyAssetIfChanged("bin/$abi/one-kvm-android-host", target)
|
||||
copyAssetIfChanged("bin/$abi/libc++_shared.so", File(binDir, "libc++_shared.so"))
|
||||
copyAssetIfChanged("bin/$abi/libasound.so", File(binDir, "libasound.so"))
|
||||
copyAssetIfChanged("bin/$abi/libopus.so", File(binDir, "libopus.so"))
|
||||
copyAssetDirectoryIfChanged("bin/$abi/alsa", File(binDir, "alsa"))
|
||||
if (!target.setExecutable(true, false)) {
|
||||
throw IllegalStateException("cannot mark host binary executable")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun copyAssetIfChanged(assetPath: String, target: File) {
|
||||
val stamp = File(target.parentFile, "${target.name}.stamp")
|
||||
@Suppress("DEPRECATION")
|
||||
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetPath"
|
||||
if (target.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
|
||||
target.parentFile?.mkdirs()
|
||||
assets.open(assetPath).use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
stamp.writeText(expectedStamp)
|
||||
}
|
||||
|
||||
private fun copyAssetDirectoryIfChanged(assetDir: String, targetDir: File) {
|
||||
@Suppress("DEPRECATION")
|
||||
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
val stamp = File(targetDir, ".stamp")
|
||||
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetDir"
|
||||
if (targetDir.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
|
||||
if (targetDir.exists()) targetDir.deleteRecursively()
|
||||
copyAssetDirectory(assetDir, targetDir)
|
||||
stamp.writeText(expectedStamp)
|
||||
}
|
||||
|
||||
private fun copyAssetDirectory(assetDir: String, targetDir: File) {
|
||||
targetDir.mkdirs()
|
||||
val children = assets.list(assetDir)?.filter { it.isNotEmpty() }.orEmpty()
|
||||
for (child in children) {
|
||||
val childAsset = "$assetDir/$child"
|
||||
val childTarget = File(targetDir, child)
|
||||
val grandChildren = assets.list(childAsset)?.filter { it.isNotEmpty() }.orEmpty()
|
||||
if (grandChildren.isEmpty()) {
|
||||
copyAssetIfChanged(childAsset, childTarget)
|
||||
} else {
|
||||
copyAssetDirectory(childAsset, childTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRootHost(
|
||||
executable: File,
|
||||
dataDir: File,
|
||||
rustLog: String,
|
||||
rustLogFile: File,
|
||||
logLevel: String,
|
||||
) {
|
||||
stopRootHost(executable)
|
||||
waitForPortRelease(8080, 2_000)
|
||||
val libDir = executable.parentFile?.absolutePath
|
||||
?: throw IllegalStateException("host binary has no parent directory")
|
||||
val alsaConfigDir = File(executable.parentFile, "alsa")
|
||||
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
|
||||
val command =
|
||||
"export LD_LIBRARY_PATH=${shellQuote(libDir)}:\${LD_LIBRARY_PATH:-}; " +
|
||||
"export ALSA_CONFIG_DIR=${shellQuote(alsaConfigDir.absolutePath)}; " +
|
||||
"export ALSA_CONFIG_PATH=${shellQuote(alsaConfigPath.absolutePath)}; " +
|
||||
"export RUST_LOG=${shellQuote(rustLog)}; " +
|
||||
"export ONE_KVM_FFMPEG_LOG=${shellQuote(ffmpegLogLevel(logLevel))}; " +
|
||||
"export ONE_KVM_ANDROID_LOG_FILE=${shellQuote(rustLogFile.absolutePath)}; " +
|
||||
"${shellQuote(executable.absolutePath)} ${shellQuote(dataDir.absolutePath)} 0.0.0.0 8080"
|
||||
val process = ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
rootProcess = process
|
||||
|
||||
Thread {
|
||||
val readError = runCatching {
|
||||
BufferedReader(InputStreamReader(process.inputStream)).useLines { lines ->
|
||||
lines.forEach {
|
||||
android.util.Log.i("OneKvmService", it)
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
if (readError != null && readError !is InterruptedIOException) {
|
||||
android.util.Log.w("OneKvmService", "Root host log reader stopped", readError)
|
||||
LogStore.append("Root host log reader stopped: ${readError.message ?: readError::class.java.simpleName}")
|
||||
}
|
||||
val exit = runCatching { process.waitFor() }.getOrNull()
|
||||
if (rootProcess === process && exit != null) {
|
||||
rootProcess = null
|
||||
ServiceStatusStore.setError(this, "Root host exited with code $exit")
|
||||
}
|
||||
}.start()
|
||||
|
||||
Thread.sleep(500)
|
||||
val exit = runCatching { process.exitValue() }.getOrNull()
|
||||
if (exit != null) {
|
||||
rootProcess = null
|
||||
throw IllegalStateException("root host exited immediately: $exit")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val invoked = runCatching {
|
||||
val method = Service::class.java.getMethod(
|
||||
"startForeground",
|
||||
Int::class.javaPrimitiveType,
|
||||
Notification::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
method.invoke(this, id, notification, foregroundServiceTypeConnectedDevice())
|
||||
}.isSuccess
|
||||
if (invoked) return
|
||||
}
|
||||
super.startForeground(id, notification)
|
||||
}
|
||||
|
||||
private fun foregroundServiceTypeConnectedDevice(): Int {
|
||||
return try {
|
||||
Service::class.java.getField("FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE").getInt(null)
|
||||
} catch (_: ReflectiveOperationException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRootHost(executable: File? = null) {
|
||||
rootProcess?.destroy()
|
||||
rootProcess = null
|
||||
stopRootHostProcess(executable)
|
||||
}
|
||||
|
||||
private fun stopRootHostProcess(executable: File? = null) {
|
||||
val command = buildString {
|
||||
append("pkill -TERM -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
|
||||
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -TERM \"${'$'}pid\" 2>/dev/null || true; done; ")
|
||||
append("sleep 0.2; ")
|
||||
append("pkill -KILL -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
|
||||
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -KILL \"${'$'}pid\" 2>/dev/null || true; done; ")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
.waitFor()
|
||||
}.onFailure { err ->
|
||||
LogStore.append("Failed to stop stale root host: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForPortRelease(port: Int, timeoutMs: Long) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val inUse = isPortOpen(port, 100)
|
||||
if (!inUse) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(port: Int, timeoutMs: Int): Boolean {
|
||||
return runCatching {
|
||||
java.net.Socket().use { socket ->
|
||||
socket.connect(java.net.InetSocketAddress("127.0.0.1", port), timeoutMs)
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun shellQuote(value: String): String {
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
private fun ffmpegLogLevel(level: String): String {
|
||||
return when (level) {
|
||||
"trace" -> "trace"
|
||||
"debug" -> "debug"
|
||||
"info" -> "info"
|
||||
"warn" -> "warning"
|
||||
else -> "error"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "one_kvm_host"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_START = "cn.one_kvm.androidhost.START"
|
||||
const val ACTION_STOP = "cn.one_kvm.androidhost.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, OneKvmService::class.java).setAction(ACTION_START)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(Intent(context, OneKvmService::class.java).setAction(ACTION_STOP))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cn.one_kvm.androidhost
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object ServiceStatusStore {
|
||||
private const val PREFS = "one_kvm_android_status"
|
||||
private const val KEY_STATE = "state"
|
||||
private const val KEY_MESSAGE = "message"
|
||||
private const val KEY_UPDATED_AT = "updated_at"
|
||||
|
||||
const val STATE_STOPPED = "stopped"
|
||||
const val STATE_STARTING = "starting"
|
||||
const val STATE_RUNNING = "running"
|
||||
const val STATE_STOPPING = "stopping"
|
||||
const val STATE_ERROR = "error"
|
||||
|
||||
data class Snapshot(
|
||||
val state: String,
|
||||
val message: String,
|
||||
val updatedAt: Long,
|
||||
) {
|
||||
fun labelText(): String {
|
||||
return when (state) {
|
||||
STATE_STARTING -> "启动中"
|
||||
STATE_RUNNING -> "运行中"
|
||||
STATE_STOPPING -> "停止中"
|
||||
STATE_ERROR -> "错误"
|
||||
else -> "已停止"
|
||||
}
|
||||
}
|
||||
|
||||
fun displayText(): String {
|
||||
val label = labelText()
|
||||
return if (message.isBlank()) label else "$label:$message"
|
||||
}
|
||||
}
|
||||
|
||||
fun setStarting(context: Context, message: String = "正在启动服务") {
|
||||
write(context, STATE_STARTING, message)
|
||||
}
|
||||
|
||||
fun setRunning(context: Context, message: String) {
|
||||
write(context, STATE_RUNNING, message)
|
||||
}
|
||||
|
||||
fun setStopping(context: Context, message: String = "正在停止服务") {
|
||||
write(context, STATE_STOPPING, message)
|
||||
}
|
||||
|
||||
fun setStopped(context: Context, message: String = "服务已停止") {
|
||||
write(context, STATE_STOPPED, message)
|
||||
}
|
||||
|
||||
fun setError(context: Context, message: String) {
|
||||
write(context, STATE_ERROR, message)
|
||||
}
|
||||
|
||||
fun snapshot(context: Context): Snapshot {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return Snapshot(
|
||||
state = prefs.getString(KEY_STATE, STATE_STOPPED) ?: STATE_STOPPED,
|
||||
message = prefs.getString(KEY_MESSAGE, "") ?: "",
|
||||
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun write(context: Context, state: String, message: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_STATE, state)
|
||||
.putString(KEY_MESSAGE, message)
|
||||
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
38
android/app/src/main/res/drawable/ic_launcher_one_kvm.xml
Normal file
38
android/app/src/main/res/drawable/ic_launcher_one_kvm.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="#1D7BF2"
|
||||
android:pathData="M24,0h60a24,24 0,0 1,24 24v60a24,24 0,0 1,-24 24H24a24,24 0,0 1,-24 -24V24a24,24 0,0 1,24 -24z" />
|
||||
<path
|
||||
android:fillColor="#AED8E8"
|
||||
android:pathData="M29,25h50a3,3 0,0 1,3 3v31a3,3 0,0 1,-3 3H29a3,3 0,0 1,-3 -3V28a3,3 0,0 1,3 -3z" />
|
||||
<path
|
||||
android:fillColor="#3F3F3D"
|
||||
android:pathData="M31,30h46v27H31z" />
|
||||
<path
|
||||
android:fillColor="#E7F1F4"
|
||||
android:pathData="M31,26h10a1.4,1.4 0,0 1,0 2.8H31a1.4,1.4 0,0 1,0 -2.8z" />
|
||||
<path
|
||||
android:fillColor="#8BBFD1"
|
||||
android:pathData="M49,62h10l1.5,8h-13z" />
|
||||
<path
|
||||
android:fillColor="#9FCFE0"
|
||||
android:pathData="M40,70a14,4.5 0,1 0,28 0a14,4.5 0,1 0,-28 0z" />
|
||||
<path
|
||||
android:fillColor="#E8F5F8"
|
||||
android:pathData="M45,70a7,1.8 0,1 0,14 0a7,1.8 0,1 0,-14 0z" />
|
||||
<path
|
||||
android:fillColor="#BFE6F1"
|
||||
android:pathData="M32,76h38l5,8H27z" />
|
||||
<path
|
||||
android:fillColor="#76ADC2"
|
||||
android:pathData="M28,84h47v2H28z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.82"
|
||||
android:pathData="M37,79h6v2h-6zM46,79h5v2h-5zM54,79h5v2h-5zM62,79h6v2h-6zM34,82h7v2h-7zM44,82h6v2h-6zM53,82h11v2H53zM67,82h4v2h-4z" />
|
||||
</vector>
|
||||
13
android/app/src/main/res/drawable/ic_stat_one_kvm.xml
Normal file
13
android/app/src/main/res/drawable/ic_stat_one_kvm.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,5h16v10H4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,17h6v2h3v2H6v-2h3z" />
|
||||
</vector>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">One-KVM Android Host</string>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/styles.xml
Normal file
7
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user