feat: 新增安卓平台支持

This commit is contained in:
mofeng-git
2026-05-24 08:37:19 +00:00
parent dc6475776e
commit b31aae284d
105 changed files with 7900 additions and 473 deletions

7
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gradle/
.kotlin/
build/
local.properties
app/build/
app/src/main/jniLibs/
native/target/

View File

@@ -0,0 +1,566 @@
import org.gradle.api.tasks.Exec
import java.security.MessageDigest
import java.util.Properties
plugins {
id("com.android.application")
}
val androidNdkVersion = "27.3.13750724"
val androidApiLevel = 21
val nativeCrateDir = layout.projectDirectory.dir("../native")
val rootCrateDir = layout.projectDirectory.dir("../..")
val nativeCargoOutputDir = layout.buildDirectory.dir("generated/oneKvm/cargoJniLibs")
val nativeOutputRoot = layout.buildDirectory.dir("generated/oneKvm/jniLibs")
val nativeAssetRoot = layout.buildDirectory.dir("generated/oneKvm/assets")
val defaultAndroidFfmpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-ffmpeg-mediacodec")
val defaultAndroidLibyuvRoot = rootProject.layout.projectDirectory.dir("../dist/android-libyuv")
val defaultAndroidTurbojpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-turbojpeg")
val defaultAndroidAlsaRoot = rootProject.layout.projectDirectory.dir("../dist/android-alsa")
val defaultAndroidOpusRoot = rootProject.layout.projectDirectory.dir("../dist/android-opus")
val androidFfmpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_FFMPEG_ROOT")
.orElse(defaultAndroidFfmpegRoot.asFile.absolutePath)
val androidLibyuvRoot = providers.environmentVariable("ONE_KVM_ANDROID_LIBYUV_ROOT")
.orElse(defaultAndroidLibyuvRoot.asFile.absolutePath)
val androidTurbojpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_TURBOJPEG_ROOT")
.orElse(defaultAndroidTurbojpegRoot.asFile.absolutePath)
val androidAlsaRoot = providers.environmentVariable("ONE_KVM_ANDROID_ALSA_ROOT")
.orElse(defaultAndroidAlsaRoot.asFile.absolutePath)
val androidOpusRoot = providers.environmentVariable("ONE_KVM_ANDROID_OPUS_ROOT")
.orElse(defaultAndroidOpusRoot.asFile.absolutePath)
val selectedAndroidAbis = providers.environmentVariable("ONE_KVM_ANDROID_ABIS")
.orElse("arm64-v8a,armeabi-v7a")
.get()
.split(',', ' ', ';')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
val androidBuildProfile = providers.environmentVariable("ONE_KVM_ANDROID_PROFILE")
.orElse("debug")
.get()
.lowercase()
val androidFfmpegSourceDir = rootProject.layout.projectDirectory
.dir("../.tmp/android-ffmpeg-check/src/ffmpeg-rockchip")
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
val androidSdkDir = file(
providers.environmentVariable("ANDROID_HOME")
.orElse(providers.environmentVariable("ANDROID_SDK_ROOT"))
.orElse(localProperties.getProperty("sdk.dir") ?: "/root/android-sdk")
.get(),
)
val androidNdkDir = androidSdkDir.resolve("ndk/$androidNdkVersion")
val androidFfmpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-ffmpeg-mediacodec.sh")
val androidLibyuvBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-libyuv.sh")
val androidTurbojpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-turbojpeg.sh")
val androidAlsaBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-alsa.sh")
val androidOpusBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-opus.sh")
val androidAbiTargets = mapOf(
"arm64-v8a" to Triple("arm64", "aarch64-linux-android", "aarch64-linux-android"),
"armeabi-v7a" to Triple("arm32", "armv7-linux-androideabi", "arm-linux-androideabi"),
)
val selectedAndroidAbiTargets = selectedAndroidAbis.associateWith { abi ->
androidAbiTargets[abi] ?: throw GradleException(
"Unsupported ONE_KVM_ANDROID_ABIS entry: $abi. Supported values: ${androidAbiTargets.keys.joinToString(", ")}",
)
}
if (androidBuildProfile != "debug" && androidBuildProfile != "release") {
throw GradleException("Unsupported ONE_KVM_ANDROID_PROFILE: $androidBuildProfile. Use debug or release.")
}
fun androidFfmpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidFfmpegRequiredFiles(root: File): List<File> = listOf(
"include/libavcodec/avcodec.h",
"lib/libavcodec.a",
"lib/libavutil.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidLibyuvBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
val turbojpegScriptDigest = MessageDigest.getInstance("SHA-256")
.digest(androidTurbojpegBuildScript.asFile.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest;turbojpegScript=$turbojpegScriptDigest"
}
fun androidLibyuvRequiredFiles(root: File): List<File> = listOf(
"include/libyuv.h",
"lib/libyuv.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidTurbojpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidAlsaBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidOpusBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidTurbojpegRequiredFiles(root: File): List<File> = listOf(
"include/turbojpeg.h",
"include/jpeglib.h",
"lib/libjpeg.a",
"lib/libturbojpeg.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidAlsaRequiredFiles(root: File): List<File> = listOf(
"include/alsa/asoundlib.h",
"lib/libasound.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidOpusRequiredFiles(root: File): List<File> = listOf(
"include/opus/opus.h",
"lib/libopus.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
android {
namespace = "cn.one_kvm.androidhost"
compileSdk = 36
ndkVersion = androidNdkVersion
flavorDimensions += "abi"
defaultConfig {
applicationId = "cn.one_kvm.androidhost"
minSdk = androidApiLevel
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
productFlavors {
create("arm32") {
dimension = "abi"
ndk {
abiFilters += "armeabi-v7a"
}
}
create("arm64") {
dimension = "abi"
ndk {
abiFilters += "arm64-v8a"
}
}
}
sourceSets {
getByName("main") {
assets.directories.clear()
jniLibs.directories.clear()
}
getByName("arm32") {
assets.directories.add("build/generated/oneKvm/assets/arm32")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm32")
}
getByName("arm64") {
assets.directories.add("build/generated/oneKvm/assets/arm64")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm64")
}
}
}
tasks.register<Exec>("buildAndroidFfmpegMediaCodec") {
description = "Builds the default Android FFmpeg MediaCodec static libraries."
group = "build"
val ffmpegRoot = file(androidFfmpegRoot.get())
val sourceDir = androidFfmpegSourceDir.asFile
val scriptFile = androidFfmpegBuildScript.asFile
val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--source",
sourceDir.absolutePath,
"--output",
ffmpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.dir(sourceDir)
inputs.file(scriptFile)
outputs.dir(ffmpegRoot)
onlyIf {
val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile)
if (!hasAndroidFfmpeg && !sourceDir.resolve("configure").exists()) {
throw GradleException(
"Missing Android FFmpeg MediaCodec build at ${ffmpegRoot.absolutePath}, " +
"and source was not found at ${sourceDir.absolutePath}",
)
}
!hasAndroidFfmpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidFfmpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidLibyuv") {
description = "Builds Android libyuv static libraries."
group = "build"
val libyuvRoot = file(androidLibyuvRoot.get())
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidLibyuvBuildScript.asFile
val stampFile = libyuvRoot.resolve(".one-kvm-android-libyuv.stamp")
dependsOn("buildAndroidTurbojpeg")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
libyuvRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
"--jpeg-root",
turbojpegRoot.absolutePath,
)
inputs.file(scriptFile)
outputs.dir(libyuvRoot)
onlyIf {
val hasAndroidLibyuv = androidLibyuvRequiredFiles(libyuvRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidLibyuvBuildStamp(scriptFile)
!hasAndroidLibyuv || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidLibyuvBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidTurbojpeg") {
description = "Builds Android TurboJPEG static libraries."
group = "build"
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidTurbojpegBuildScript.asFile
val stampFile = turbojpegRoot.resolve(".one-kvm-android-turbojpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
turbojpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(turbojpegRoot)
onlyIf {
val hasAndroidTurbojpeg = androidTurbojpegRequiredFiles(turbojpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidTurbojpegBuildStamp(scriptFile)
!hasAndroidTurbojpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidTurbojpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidAlsa") {
description = "Builds Android ALSA shared libraries."
group = "build"
val alsaRoot = file(androidAlsaRoot.get())
val scriptFile = androidAlsaBuildScript.asFile
val stampFile = alsaRoot.resolve(".one-kvm-android-alsa.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
alsaRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(alsaRoot)
onlyIf {
val hasAndroidAlsa = androidAlsaRequiredFiles(alsaRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidAlsaBuildStamp(scriptFile)
!hasAndroidAlsa || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidAlsaBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidOpus") {
description = "Builds Android Opus shared libraries."
group = "build"
val opusRoot = file(androidOpusRoot.get())
val scriptFile = androidOpusBuildScript.asFile
val stampFile = opusRoot.resolve(".one-kvm-android-opus.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
opusRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(opusRoot)
onlyIf {
val hasAndroidOpus = androidOpusRequiredFiles(opusRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidOpusBuildStamp(scriptFile)
!hasAndroidOpus || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidOpusBuildStamp(scriptFile))
}
}
val cargoBuildAndroidAbiTaskNames = selectedAndroidAbiTargets.map { (abi, targets) ->
val (flavor, _, _) = targets
val taskName = "cargoBuildAndroid" + flavor.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
tasks.register<Exec>(taskName) {
description = "Builds the Android Rust bootstrap libraries for $abi."
group = "build"
dependsOn(
"buildAndroidFfmpegMediaCodec",
"buildAndroidLibyuv",
"buildAndroidTurbojpeg",
"buildAndroidAlsa",
"buildAndroidOpus",
)
val cargoCommand = mutableListOf(
"cargo",
"ndk",
"-t",
abi,
"-P",
androidApiLevel.toString(),
"-o",
nativeCargoOutputDir.get().asFile.absolutePath,
"build",
"--lib",
"--bins",
)
if (androidBuildProfile == "release") {
cargoCommand.add("--release")
}
workingDir = nativeCrateDir.asFile
commandLine(cargoCommand)
args("--features", "android-mediacodec")
environment("ONE_KVM_ANDROID_FFMPEG_ROOT", androidFfmpegRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_ROOT", androidLibyuvRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_STATIC", "1")
environment("TURBOJPEG_SOURCE", "explicit")
environment("TURBOJPEG_STATIC", "1")
environment(
"TURBOJPEG_LIB_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/lib").absolutePath,
)
environment(
"TURBOJPEG_INCLUDE_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/include").absolutePath,
)
environment("PKG_CONFIG_ALLOW_CROSS", "1")
environment(
"PKG_CONFIG_LIBDIR",
file(androidAlsaRoot.get()).resolve("$abi/lib/pkgconfig").absolutePath,
)
environment("PKG_CONFIG_SYSROOT_DIR", "")
environment("LIBOPUS_NO_PKG", "1")
environment("LIBOPUS_LIB_DIR", file(androidOpusRoot.get()).resolve("$abi/lib").absolutePath)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_SDK_ROOT", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", androidNdkDir.absolutePath)
environment("ANDROID_NDK", androidNdkDir.absolutePath)
environment("ANDROID_NDK_ROOT", androidNdkDir.absolutePath)
inputs.files(
nativeCrateDir.file("Cargo.toml"),
nativeCrateDir.dir("src"),
rootCrateDir.file("Cargo.lock"),
rootCrateDir.file("Cargo.toml"),
rootCrateDir.file("build.rs"),
rootCrateDir.dir("libs"),
rootCrateDir.dir("res/vcpkg/libyuv"),
rootCrateDir.dir("src"),
)
outputs.dir(nativeCargoOutputDir)
outputs.dir(file(androidFfmpegRoot.get()))
outputs.dir(file(androidLibyuvRoot.get()))
outputs.dir(file(androidTurbojpegRoot.get()))
outputs.dir(file(androidAlsaRoot.get()))
outputs.dir(file(androidOpusRoot.get()))
}
taskName
}
tasks.register("cargoBuildAndroid") {
description = "Builds the Android Rust bootstrap libraries."
group = "build"
dependsOn(cargoBuildAndroidAbiTaskNames)
outputs.dir(nativeOutputRoot)
outputs.dir(nativeAssetRoot)
doLast {
selectedAndroidAbiTargets.forEach { (abi, targets) ->
val (flavor, rustTriple, ndkTriple) = targets
val nativeLibSource = nativeCargoOutputDir.get().file("$abi/libone_kvm_android_bootstrap.so").asFile
if (!nativeLibSource.exists()) {
throw GradleException("Missing Android JNI library: ${nativeLibSource.absolutePath}")
}
copy {
from(nativeLibSource)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
val source = nativeCrateDir.file("target/$rustTriple/$androidBuildProfile/one-kvm-android-host").asFile
if (!source.exists()) {
throw GradleException("Missing Android host binary: ${source.absolutePath}")
}
copy {
from(source)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
rename { "one-kvm-android-host" }
}
val cxxShared = androidNdkDir
.resolve("toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$ndkTriple/libc++_shared.so")
if (!cxxShared.exists()) {
throw GradleException("Missing NDK libc++_shared.so: ${cxxShared.absolutePath}")
}
copy {
from(cxxShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(cxxShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
val alsaShared = file(androidAlsaRoot.get()).resolve("$abi/lib/libasound.so")
if (!alsaShared.exists()) {
throw GradleException("Missing Android ALSA library: ${alsaShared.absolutePath}")
}
copy {
from(alsaShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(alsaShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
copy {
from(file(androidAlsaRoot.get()).resolve("$abi/share/alsa"))
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi/alsa"))
}
val opusShared = file(androidOpusRoot.get()).resolve("$abi/lib/libopus.so")
if (!opusShared.exists()) {
throw GradleException("Missing Android Opus library: ${opusShared.absolutePath}")
}
copy {
from(opusShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(opusShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
}
}
}
tasks.named("preBuild") {
dependsOn("cargoBuildAndroid")
}

View 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>

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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"
}
}

View 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)
}
}

View 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
}
}

View File

@@ -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
}

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">One-KVM Android Host</string>
</resources>

View 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>

3
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.0.0" apply false
}

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

21
android/native/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "one-kvm-android-bootstrap"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "one_kvm_android_bootstrap"
crate-type = ["cdylib"]
[[bin]]
name = "one-kvm-android-host"
path = "src/bin/one-kvm-android-host.rs"
[dependencies]
jni = "0.22.4"
one-kvm = { path = "../..", default-features = false, features = ["android", "android-mediacodec"] }
rustls-platform-verifier = "0.7"
[features]
android-mediacodec = ["one-kvm/android-mediacodec"]

View File

@@ -0,0 +1,24 @@
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
fn main() {
let mut args = std::env::args().skip(1);
let data_dir = args
.next()
.unwrap_or_else(|| "/data/local/tmp/one-kvm".to_string());
let bind_address = args.next().unwrap_or_else(|| "0.0.0.0".to_string());
let port = args
.next()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(8080);
one_kvm::runtime::android::init_rustls_provider();
if let Err(err) = android::run_foreground(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
}) {
eprintln!("one-kvm android host failed: {err}");
std::process::exit(1);
}
}

182
android/native/src/lib.rs Normal file
View File

@@ -0,0 +1,182 @@
use jni::errors::{ErrorPolicy, ThrowRuntimeExAndDefault};
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::{Env, EnvOutcome, EnvUnowned};
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
#[derive(Debug)]
struct BridgeError(String);
impl From<jni::errors::Error> for BridgeError {
fn from(err: jni::errors::Error) -> Self {
Self(err.to_string())
}
}
impl From<String> for BridgeError {
fn from(err: String) -> Self {
Self(err)
}
}
impl std::fmt::Display for BridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Default)]
struct StatusPolicy;
impl ErrorPolicy<jint, BridgeError> for StatusPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_err: BridgeError,
) -> jni::errors::Result<jint> {
Ok(-1)
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<jint> {
Ok(-1)
}
}
#[derive(Debug, Default)]
struct StringResultPolicy;
impl ErrorPolicy<String, BridgeError> for StringResultPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
err: BridgeError,
) -> jni::errors::Result<String> {
Ok(format!("start failed: {err}"))
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<String> {
Ok("start failed: panic in native bridge".to_string())
}
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_setEnv<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
name: JString<'local>,
value: JString<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> = env.with_env_no_catch(|env| {
let name = name
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env name: {err}")))?;
let value = value
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env value: {err}")))?;
if name.contains('\0') || value.contains('\0') {
return Err(BridgeError("env contains NUL".to_string()));
}
std::env::set_var(name, value);
Ok(0)
});
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_initTlsVerifier<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> =
env.with_env_no_catch(|env| init_tls_verifier(env, context));
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[cfg(target_os = "android")]
fn init_tls_verifier(env: &mut Env<'_>, context: JObject<'_>) -> Result<jint, BridgeError> {
rustls_platform_verifier::android::init_with_env(env, context)
.map_err(|err| BridgeError(format!("failed to initialize rustls platform verifier: {err}")))?;
Ok(0)
}
#[cfg(not(target_os = "android"))]
fn init_tls_verifier(_env: &mut Env<'_>, _context: JObject<'_>) -> Result<jint, BridgeError> {
Ok(0)
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_startHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
data_dir: JString<'local>,
bind_address: JString<'local>,
port: i32,
) -> jstring {
let outcome: EnvOutcome<'local, String, BridgeError> = env.with_env_no_catch(|env| {
let data_dir = data_dir
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid data dir: {err}")))?;
let bind_address = bind_address
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid bind address: {err}")))?;
let port = u16::try_from(port).map_err(|_| BridgeError("invalid port".to_string()))?;
android::start(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
})
.map_err(BridgeError)
});
let result = outcome.resolve_with::<StringResultPolicy, _>(|| ());
env.with_env_no_catch(|env| env.new_string(result))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_stopHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::stop()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_hostStatus<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::status()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_kernelVersion<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OneKvmAndroidHost"
include(":app")