mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
feat: 新增安卓平台支持
This commit is contained in:
7
android/.gitignore
vendored
Normal file
7
android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
build/
|
||||
local.properties
|
||||
app/build/
|
||||
app/src/main/jniLibs/
|
||||
native/target/
|
||||
566
android/app/build.gradle.kts
Normal file
566
android/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
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>
|
||||
3
android/build.gradle.kts
Normal file
3
android/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.0" apply false
|
||||
}
|
||||
3
android/gradle.properties
Normal file
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
android/gradlew
vendored
Normal 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
94
android/gradlew.bat
vendored
Normal 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
21
android/native/Cargo.toml
Normal 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"]
|
||||
24
android/native/src/bin/one-kvm-android-host.rs
Normal file
24
android/native/src/bin/one-kvm-android-host.rs
Normal 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
182
android/native/src/lib.rs
Normal 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()
|
||||
}
|
||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user